code:
import {
  GENERIC_CANCEL,
  GENERIC_CLOSE,
  GENERIC_CONFIRM,
  GENERIC_INTERNET_ERROR,
  USER_TOKEN_CHANGE_CHECKING,
  USER_TOKEN_CHANGE_DESC,
  USER_TOKEN_CHANGE_EMPTY,
  USER_TOKEN_CHANGE_INPUT_LABEL,
  USER_TOKEN_CHANGE_INVALID,
  USER_TOKEN_CHANGE_SUCCESS,
  USER_TOKEN_CHANGE_TITLE,
  USER_TOKEN_DOES_NOT_EXIST,
  USER_UPDATE_PROFILE_DESC,
  USER_UPDATE_PROFILE_DISPLAY_NAME_EMPTY,
  USER_UPDATE_PROFILE_DISPLAY_NAME_INPUT_LABEL,
  USER_UPDATE_PROFILE_EMAIL_INPUT_LABEL,
  USER_UPDATE_PROFILE_ERROR_NOT_INITIALIZED,
  USER_UPDATE_PROFILE_HINT_DISPLAY_NAME,
  USER_UPDATE_PROFILE_HINT_EMAIL_0,
  USER_UPDATE_PROFILE_HINT_EMAIL_1,
  USER_UPDATE_PROFILE_HINT_EMAIL_GRAVATAR_LINK,
  USER_UPDATE_PROFILE_HINT_USER_NAME,
  USER_UPDATE_PROFILE_LOADING,
  USER_UPDATE_PROFILE_SUCCESS,
  USER_UPDATE_PROFILE_TITLE,
  USER_UPDATE_PROFILE_USER_NAME_INPUT_LABEL
} from '../constant/messages';
import { pageHref } from '../data/hrefs';
import { DebugLogger } from '../DebugLogger';
import { h } from '../hs';
import { ItemDecoration, ItemHandle, ItemLocation } from '../Menu';
import { externalLink } from '../util/DOM';
import { fetchInit, fetchRegister, fetchUpdateProfile, getErrorMessage, ResultOf } from './backendControl';
import { mainMenu } from './menuControl';
import { Modal, showGenericError, showGenericHint, showGenericLoading, showGenericSuccess } from './modalControl';
import { StringPersistentItem } from './persistentItem';

const debugLogger = new DebugLogger('User Control');

export const tokenItem = new StringPersistentItem('token');
let initializedData: {
  userName: string,
  displayName: string,
  email: string | null,
} | null = null;

export async function registerWithResultPopup(displayName: string, email: string | null): Promise<boolean> {
  let result: ResultOf<typeof fetchRegister>;
  try {
    result = await fetchRegister(displayName, email ?? undefined);
  } catch (error) {
    showGenericError(GENERIC_INTERNET_ERROR);
    debugLogger.error(error);
    return false;
  }
  if (!result.success) {
    showGenericError(getErrorMessage(result.code));
    return false;
  } else {
    tokenItem.setValue(result.token);
    initializedData = {
      displayName,
      userName: result.user_name,
      email,
    };
    return true;
  }
}

export function getCurrentUser() {
  return initializedData;
}

let newMentionLink: null | ItemHandle = null;
export function removeNewMentionLink() {
  if (newMentionLink === null) {
    return;
  }
  newMentionLink.remove();
  newMentionLink = null;
}
enum InitResult {
  SUCCESS,
  ERROR_NETWORK,
  ERROR_TOKEN,
}
export async function init(token: string) {
  // 以下代码用于从后端拉取是否有新回复
  // 这一步不会记录任何个人数据
  debugLogger.log('Initializing.');
  removeNewMentionLink();
  let data: ResultOf<typeof fetchInit>;
  try {
    data = await fetchInit(token);
  } catch (error) {
    debugLogger.warn('Initialization failed: ', error);
    return InitResult.ERROR_NETWORK;
  }
  if (data.success) {
    tokenItem.setValue(token);
    initializedData = {
      displayName: data.display_name,
      email: data.email ?? null,
      userName: data.user_name,
    };
    debugLogger.log(`Initialization result: ${data.mentions} new mentions.`);
    if (data.mentions !== 0) {
      newMentionLink = mainMenu.addItem(`您有 ${data.mentions} 条新回复`, {
        button: true,
        link: pageHref('recent-mentions'),
        decoration: ItemDecoration.ICON_NOTIFICATION,
        location: ItemLocation.BEFORE,
      });
      newMentionLink.addClass('force-small');
    }
    return InitResult.SUCCESS;
  } else {
    debugLogger.warn('Initialization failed: Invalid token.');
    return InitResult.ERROR_TOKEN;
  }
}

export function showLoginModal() {
  const initialTokenValue = tokenItem.getValue() ?? '';
  const $tokenInput: HTMLInputElement = h('input', {
    value: initialTokenValue,
  }) as HTMLInputElement;
  const $confirmButton = h('div.display-none', {
    onclick: () => {
      if ($tokenInput.value === '') {
        showGenericError(USER_TOKEN_CHANGE_EMPTY);
        return;
      }
      const loadingModal = showGenericLoading(USER_TOKEN_CHANGE_CHECKING);
      debugLogger.log('Changing token...');
      init($tokenInput.value).then(initResult => {
        if (initResult === InitResult.SUCCESS) {
          debugLogger.log('Change token successful.');
          showGenericSuccess(USER_TOKEN_CHANGE_SUCCESS).then(() => {
            loginModal.close();
          });
        } else {
          if (initResult === InitResult.ERROR_NETWORK) {
            showGenericError(GENERIC_INTERNET_ERROR);
          } else {
            showGenericError(USER_TOKEN_CHANGE_INVALID);
          }
        }
      }).finally(() => {
        loadingModal.close();
      });
    },
  }, GENERIC_CONFIRM);
  const $closeButton = h('div', {
    onclick: () => {
      loginModal.close();
    }
  }, GENERIC_CLOSE);
  $tokenInput.addEventListener('input', () => {
    if ($tokenInput.value === initialTokenValue) {
      $confirmButton.classList.add('display-none');
      $closeButton.innerText = GENERIC_CLOSE;
    } else {
      $confirmButton.classList.remove('display-none');
      $closeButton.innerText = GENERIC_CANCEL;
    }
  });
  const loginModal = new Modal(h('div', [
    h('h1', USER_TOKEN_CHANGE_TITLE),
    ...USER_TOKEN_CHANGE_DESC.split('\n').map(p => h('p', p)),
    h('.input-group', [
      h('span', USER_TOKEN_CHANGE_INPUT_LABEL),
      $tokenInput,
    ]),
    h('.button-container', [
      $confirmButton,
      $closeButton,
    ]),
  ]));
  loginModal.open();
}

export function showUpdateProfileModal() {
  return new Promise<void>(resolve => {
    if (!tokenItem.exists()) {
      showGenericHint(USER_TOKEN_DOES_NOT_EXIST);
      return;
    }
    const userData = initializedData;
    if (userData === null) {
      showGenericError(USER_UPDATE_PROFILE_ERROR_NOT_INITIALIZED);
      console.error(1);
      return;
    }
    const $nameInput: HTMLInputElement = h('input', {
      value: userData.displayName,
    }) as HTMLInputElement;
    const $emailInput: HTMLInputElement = h('input', {
      value: userData.email ?? '',
    }) as HTMLInputElement;
    const updateProfileModal = new Modal(h('div', [
      h('h1', USER_UPDATE_PROFILE_TITLE),
      ...USER_UPDATE_PROFILE_DESC.split('\n').map(p => h('p', p)),
      h('ul', [
        h('li', USER_UPDATE_PROFILE_HINT_USER_NAME),
        h('li', USER_UPDATE_PROFILE_HINT_DISPLAY_NAME),
        h('li', [
          USER_UPDATE_PROFILE_HINT_EMAIL_0,
          externalLink(USER_UPDATE_PROFILE_HINT_EMAIL_GRAVATAR_LINK, 'https://cn.gravatar.com/'),
          USER_UPDATE_PROFILE_HINT_EMAIL_1,
        ]),
      ]),
      h('.input-group', [
        h('span', USER_UPDATE_PROFILE_USER_NAME_INPUT_LABEL + '@' + userData.userName),
      ]),
      h('.input-group', [
        h('span', USER_UPDATE_PROFILE_DISPLAY_NAME_INPUT_LABEL),
        $nameInput,
      ]),
      h('.input-group', [
        h('span', USER_UPDATE_PROFILE_EMAIL_INPUT_LABEL),
        $emailInput,
      ]),
      h('.button-container', [
        h('div', {
          onclick: () => {
            if ($nameInput.value === '') {
              showGenericError(USER_UPDATE_PROFILE_DISPLAY_NAME_EMPTY);
              return;
            }
            const loadingModal = showGenericLoading(USER_UPDATE_PROFILE_LOADING);
            debugLogger.log('Updating profile...');
            const newDisplayName = $nameInput.value;
            const newEmail = ($emailInput.value === '') ? undefined : $emailInput.value;
            fetchUpdateProfile(tokenItem.getValue()!, newDisplayName, newEmail)
              .then(result => {
                if (result.success) {
                  showGenericSuccess(USER_UPDATE_PROFILE_SUCCESS).then(() => {
                    updateProfileModal.close();
                    resolve();
                  });
                  userData.displayName = newDisplayName;
                  userData.email = newEmail ?? null;
                  debugLogger.log('Update profile success');
                } else {
                  debugLogger.warn('Update profile failed, error code:', result.code, ', translated:', getErrorMessage(result.code));
                  showGenericError(getErrorMessage(result.code));
                }
              }, error => {
                showGenericError(GENERIC_INTERNET_ERROR);
                debugLogger.error('Update profile failed:', error);
              })
              .finally(() => {
                loadingModal.close();
              });
          }
        }, GENERIC_CONFIRM),
        h('div', {
          onclick: () => {
            updateProfileModal.close();
            resolve();
          }
        }, GENERIC_CANCEL),
      ]),
    ]));
    updateProfileModal.open();
  });
}


code:
import { chapterHref, pageHref } from '../data/hrefs';
import { h } from '../hs';
import { isCandidate } from '../pages/wtcupVote';
import { Content } from './contentControl';

// const voteDate = 1607007600000; // new Date('2020-12-03T23:00:00.000+08:00').getTime()
const endDate = 1609426800000; // new Date('2020-12-31T23:00:00.000+08:00').getTime()

export function isWtcupStillOn() {
  return Date.now() < endDate;
}

export function loadWtcupInfoPre(content: Content, relativePath: string) {
  if (!isWtcupStillOn()) {
    return;
  }
  if (isCandidate(relativePath)) {
    content.addBlock({
      initElement: h('div',
        h('h3', `本文正在参与第一届西塔杯年度最佳已完结文章评选！`),
        h('p',
          '评选结果由投票决定，欢迎',
          h('a.regular', {
            href: pageHref('wtcup-vote'),
          }, '点此参与投票'),
          '。'
        ),
      ),
    });
  }
}

export function loadWtcupInfo(content: Content) {
  content.addBlock({
    initElement: h('div',
      h('h3', `第一届西塔杯年度最佳已完结文章评选已经结束！`),
      h('p',
        '西塔杯选出了年度十佳作品以及三位获奖作者，',
        h('a.regular', {
          href: chapterHref('META/第一届西塔杯评选.html'),
        }, '点此查看评选结果'),
        '。'
      ),
    ),
  });
}

repo_name: de/wearable

license: unknown


code:
import { DebugLogger } from '../DebugLogger';

export class AutoCache<TKey, TValue> {
  private map = new Map<TKey, Promise<TValue>>();
  public constructor(
    private loader: (key: TKey) => Promise<TValue>,
    private logger: DebugLogger,
  ) {}
  public delete(key: TKey) {
    this.map.delete(key);
  }
  public get(key: TKey): Promise<TValue> {
    let value = this.map.get(key);
    if (value === undefined) {
      this.logger.log(`Start loading for key=${key}.`);
      value = this.loader(key);
      this.map.set(key, value);
      value.catch(error => {
        this.map.delete(key);
        this.logger.warn(
          `Loader failed for key=${key}. Cache removed.`, error,
        );
      });
    } else {
      this.logger.log(`Cached value used for key=${key}.`);
    }
    return value;
  }
}

export class AutoSingleCache<TValue> extends AutoCache<null, TValue> {
  public constructor(
    loader: () => Promise<TValue>,
    logger: DebugLogger,
  ) {
    super(loader, logger);
  }
  public delete() {
    super.delete(null);
  }
  public get(): Promise<TValue> {
    return super.get(null);
  }
}


code:
import { Chapter, Data, Folder, AuthorInfo } from '../../Data';
import { DebugLogger } from '../DebugLogger';
export const data = (window as any).DATA as Data;

export interface ChapterContext {
  folder: Folder;
  inFolderIndex: number;
  chapter: Chapter;
}

const debugLogger = new DebugLogger('Data');

export const tagCountMap: Map<string, number> = new Map();
function incrementCount(tag: string) {
  tagCountMap.set(tag, (tagCountMap.get(tag) ?? 0) + 1);
}
export const relativePathLookUpMap: Map<string, ChapterContext> = new Map();
function iterateFolder(folder: Folder) {
  folder.children.forEach((child, index) => {
    if (child.type === 'folder') {
      iterateFolder(child);
    } else {
      relativePathLookUpMap.set(child.htmlRelativePath, {
        folder,
        chapter: child,
        inFolderIndex: index,
      });
      child.tags?.forEach(tag => {
        incrementCount(tag);
        if (tag.includes('（')) {
          incrementCount(tag.substr(0, tag.indexOf('（')));
        }
      });
    }
  });
}
const startTime = Date.now();
iterateFolder(data.chapterTree);
debugLogger.log(`Iterating folders took ${Date.now() - startTime}ms.`);

export const authorInfoMap: Map<string, AuthorInfo> = new Map();
for (const authorInfo of data.authorsInfo) {
  authorInfoMap.set(authorInfo.name, authorInfo);
}

export const tagAliasMap = new Map(data.tagAliases);


code:
import { DebugLogger } from '../DebugLogger';

let db: null | Promise<IDBDatabase> = null;

const debugLogger = new DebugLogger('IndexedDB');

const migrations = new Map<number, (db: IDBDatabase) => void>([
  [0, db => {
    const readingProgress = db.createObjectStore('readingProgress', { keyPath: 'relativePath' });
    readingProgress.createIndex('lastRead', 'lastRead');
  }],
  [1, db => {
    db.createObjectStore('simpleKV');
  }]
]);

/**
 * Turn an event target into a promise.
 * ! NOTE: This does not handle onerror, as errors are expected to bubble up.
 */
 export function untilSuccess<TResult>(target: IDBRequest<TResult>) {
  return new Promise<TResult>(resolve => {
    target.onsuccess = function() {
      resolve(this.result);
    };
  });
}

type DbKVKey<TValue> = { key: string };

export function dbKVKey<TValue>(key: string): DbKVKey<TValue> {
  return { key };
}

export async function dbKVSet<TValue>(key: DbKVKey<TValue>, value: TValue) {
  const db = await getDb();
  const store = db.transaction('simpleKV', 'readwrite').objectStore('simpleKV');
  await untilSuccess(store.put(value, key.key));
}

export async function dbKVGet<TValue>(key: DbKVKey<TValue>): Promise<TValue | null> {
  const db = await getDb();
  const store = db.transaction('simpleKV', 'readwrite').objectStore('simpleKV');
  return await untilSuccess(store.get(key.key)) ?? null;
}

export function getDb() {
  if (db === null) {
    db = new Promise((resolve, reject) => {
      debugLogger.log('Open database');
      const request = window.indexedDB.open('main', 2);
      request.onsuccess = () => {
        debugLogger.log('Database successfully opened.');
        resolve(request.result);
      };
      request.onerror = () => {
        debugLogger.error('Database failed to open: ', request.error);
        reject(request.error);
      };
      request.onupgradeneeded = event => {
        debugLogger.log(`Migrating from ${event.oldVersion} to ${event.newVersion!}`);
        const db = request.result;
        for (let version = event.oldVersion; version < event.newVersion!; version++) {
          const migration = migrations.get(version);
          if (migration === undefined) {
            throw new Error(`Missing migration for version=${version}.`);
          }
          debugLogger.log(`Running migration ${version} -> ${version + 1}`);
          migration(db);
        }
        debugLogger.log('Migration completed');
      };
    });
  }
  return db;
}


code:
export function chapterHref(htmlRelativePath: string) {
  return `#/chapter/${htmlRelativePath}`;
}

export function pageHref(pageName: string) {
  return `#/page/${pageName}`;
}

export function mirrorLandingHref(origin: string, token: string | null, scroll?: number) {
  let href = `${origin}/#/mirror-landing`;
  if (token !== null) {
    href += `~token=${token}`;
  }
  if (scroll !== undefined) {
    href += `~scroll=${scroll}`;
  }
  return href;
}

export function tagSearchHref(tag: string) {
  return `#/page/tag-search/${tag}`;
}


code:
// Tracking reading progress

import { getDb, untilSuccess } from './db';

interface Entry {
  relativePath: string;
  lastRead: Date;
  /** 0 - 1 */
  progress: number;
}

async function getReadonlyStore() {
  const db = await getDb();
  return db.transaction('readingProgress').objectStore('readingProgress');
}

async function getStore() {
  const db = await getDb();
  return db.transaction('readingProgress', 'readwrite').objectStore('readingProgress');
}

export async function updateChapterProgress(relativePath: string, progress: number) {
  const store = await getStore();
  const result = (await untilSuccess(store.get(relativePath))) as (undefined | Entry);
  await untilSuccess(store.put({
    relativePath,
    lastRead: new Date(),
    progress: Math.max(progress, result?.progress ?? 0),
  }));
}

export async function getHistory(entries = 20) {
  const store = await getReadonlyStore();
  const lastReadIndex = store.index('lastRead');
  return new Promise<Array<Entry>>(resolve => {
    const results: Array<Entry> = [];
    lastReadIndex.openCursor(null, 'prev').onsuccess = function() {
      const cursor = this.result;
      if (cursor !== null) {
        results.push(cursor.value);
        if (results.length >= entries) {
          resolve(results);
        } else {
          cursor.continue();
        }
      } else {
        resolve(results);
      }
    };
  });
}


code:
import { Event } from '../Event';

const noop = () => { /* noop */ };
export class BooleanSetting {
  private value: boolean;
  public readonly event: Event<boolean> = new Event();
  public constructor(
    private key: string,
    defaultValue: boolean
  ) {
    if (defaultValue) {
      this.value = window.localStorage.getItem(key) !== 'false';
    } else {
      this.value = window.localStorage.getItem(key) === 'true';
    }
    this.updateLocalStorage();
    setImmediate(() => this.event.emit(this.value));
  }
  private updateLocalStorage() {
    window.localStorage.setItem(this.key, String(this.value));
  }
  public getValue() {
    return this.value;
  }
  public setValue(newValue: boolean) {
    if (newValue !== this.value) {
      this.event.emit(newValue);
    }
    this.value = newValue;
    this.updateLocalStorage();
  }
  public toggle() {
    this.setValue(!this.value);
  }
}

export class EnumSetting {
  private value: number;
  public constructor(
    private key: string,
    public options: Array<string>,
    private defaultValue: number,
    private onUpdate: (newValue: number, newValueName: string) => void = noop,
  ) {
    if (!this.isCorrectValue(defaultValue)) {
      throw new Error(`Default value ${defaultValue} is not correct.`);
    }
    this.value = +(window.localStorage.getItem(key) || defaultValue);
    this.correctValue();
    this.onUpdate(this.value, this.options[this.value]);
  }
  private isCorrectValue(value: number): boolean {
    return !(Number.isNaN(value) || value % 1 !== 0 || value < 0 || value >= this.options.length);
  }
  private correctValue() {
    if (!this.isCorrectValue(this.value)) {
      this.value = this.defaultValue;
    }
  }
  private updateLocalStorage() {
    window.localStorage.setItem(this.key, String(this.value));
  }
  public getValue() {
    return this.value;
  }
  public getValueName() {
    return this.options[this.value];
  }
  public setValue(newValue: number) {
    if (newValue !== this.value) {
      this.onUpdate(newValue, this.options[newValue]);
    }
    this.value = newValue;
    this.updateLocalStorage();
  }
}

export const animation = new BooleanSetting('animation', true);
animation.event.on(value => {
  setTimeout(() => {
    document.body.classList.toggle('animation-enabled', value);
  }, 1);
});
export const warning = new BooleanSetting('warning', false);
export const earlyAccess = new BooleanSetting('earlyAccess', false);
export const useComments = new BooleanSetting('useComments', true);
export const gestureSwitchChapter = new BooleanSetting('gestureSwitchChapter', true);
// https://github.com/zenozeng/fonts.css
const fontFamilyCssValues = [
  '-apple-system, "Noto Sans", "Helvetica Neue", Helvetica, "Nimbus Sans L", Arial, "Liberation Sans", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN", "Microsoft YaHei", "Wenquanyi Micro Hei", "WenQuanYi Zen Hei", "ST Heiti", SimHei, "WenQuanYi Zen Hei Sharp", sans-serif',
  'Baskerville, Georgia, "Liberation Serif", "Kaiti SC", STKaiti, "AR PL UKai CN", "AR PL UKai HK", "AR PL UKai TW", "AR PL UKai TW MBE", "AR PL KaitiM GB", KaiTi, KaiTi_GB2312, DFKai-SB, "TW\-Kai", serif',
  'Georgia, "Nimbus Roman No9 L", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", "Source Han Serif CN", STSong, "AR PL New Sung", "AR PL SungtiL GB", NSimSun, SimSun, "TW\-Sung", "WenQuanYi Bitmap Song", "AR PL UMing CN", "AR PL UMing HK", "AR PL UMing TW", "AR PL UMing TW MBE", PMingLiU, MingLiU, serif',
  'Baskerville, "Times New Roman", "Liberation Serif", STFangsong, FangSong, FangSong_GB2312, "CWTEX\-F", serif',
];
export const fontFamily = new EnumSetting('fontFamily', ['黑体', '楷体', '宋体', '仿宋'], 0, (fontFamilyIndex: number) => {
  document.documentElement.style.setProperty('--font-family', fontFamilyCssValues[fontFamilyIndex]);
  document.documentElement.style.setProperty('--font-family-mono', '"Fira Code", ' + fontFamilyCssValues[fontFamilyIndex]);
});
export const developerMode = new BooleanSetting('developerMode', false);
export const charCount = new BooleanSetting('charCount', true);
export const wtcdGameQuickLoadConfirm = new BooleanSetting('wtcdGameQuickLoadConfirm', true);
export const contactInfo = new BooleanSetting('contactInfo', true);
export const showAbandonedChapters = new BooleanSetting('showAbandonedChapters', false);
export const enablePushHelper = new BooleanSetting('enablePushHelper', false);
export const chapterRecommendationCount = new EnumSetting('chapterRecommendationCount', ['禁用', '5 条', '10 条', '15 条', '20 条', '25 条', '30 条', '35 条', '40 条', '45 条', '50 条'], 2);


code:
// import { ChapterContext } from './data';

// export type Selection = [number, number, number, number];
// interface State {
//   currentChapter: ChapterContext | null;
//   chapterSelection: Selection | null;
//   chapterTextNodes: Array<Text> | null;
// }
// export const state: State = {
//   currentChapter: null,
//   chapterSelection: null,
//   chapterTextNodes: null,
// };


code:
import * as hs from 'hyperscript';

export const h = hs;


code:
import { BUILD_FAILED_DESC, BUILD_FAILED_OK, BUILD_FAILED_TITLE } from './constant/messages';
import './control/analyticsControl';
import { followQuery, initPathHandlers } from './control/followQuery';
import { initLoseContactPrevention } from './control/loseContactPreventionControl';
import { notify } from './control/modalControl';
import './control/navbarControl';
import { init, tokenItem } from './control/userControl';
import { data } from './data/data';
import { animation } from './data/settings';
import { id } from './util/DOM';

const $warning = id('warning');

if ($warning !== null) {
  $warning.addEventListener('click', () => {
    $warning.style.opacity = '0';
    if (animation.getValue()) {
      $warning.addEventListener('transitionend', () => {
        $warning.remove();
      });
    } else {
      $warning.remove();
    }
  });
}

const $buildNumber = id('build-number');
$buildNumber.innerText = `Build ${data.buildNumber}`;

if (tokenItem.exists()) {
  init(tokenItem.getValue()!);
}

if (data.buildError) {
  notify(BUILD_FAILED_TITLE, BUILD_FAILED_DESC, BUILD_FAILED_OK);
}

initPathHandlers();

window.addEventListener('popstate', () => {
  followQuery();
});

followQuery();

initLoseContactPrevention();


code:
import { DebugLogger } from '../DebugLogger';
import { Event } from '../Event';
import { isAnyParent } from '../util/DOM';

export enum SwipeDirection {
  TO_TOP,
  TO_RIGHT,
  TO_BOTTOM,
  TO_LEFT,
}

const gestureMinWidth = 900;

export const swipeEvent: Event<SwipeDirection> = new Event();
const horizontalMinXProportion = 0.17;
const horizontalMaxYProportion = 0.1;
const verticalMinYProportion = 0.1;
const verticalMaxProportion = 0.1;
const swipeTimeThreshold = 500;
let startX = 0;
let startY = 0;
let startTime = 0;
let startTarget: EventTarget | null = null;
window.addEventListener('touchstart', event => {
  // Only listen for first touch starts
  if (event.touches.length !== 1) {
    return;
  }
  startTarget = event.target;
  startX = event.touches[0].clientX;
  startY = event.touches[0].clientY;
  startTime = Date.now();
});
window.addEventListener('touchend', event => {
  // Only listen for last touch ends
  if (event.touches.length !== 0) {
    return;
  }
  // Ignore touches that lasted too long
  if (Date.now() - startTime > swipeTimeThreshold) {
    return;
  }
  if (window.innerWidth > gestureMinWidth) {
    return;
  }
  const deltaX = event.changedTouches[0].clientX - startX;
  const deltaY = event.changedTouches[0].clientY - startY;
  const xProportion = Math.abs(deltaX / window.innerWidth);
  const yProportion = Math.abs(deltaY / window.innerHeight);
  if (xProportion > horizontalMinXProportion && yProportion < horizontalMaxYProportion) {
    // Horizontal swipe detected
    // Check for scrollable element
    if (isAnyParent(startTarget as HTMLElement, $element => (
      (window.getComputedStyle($element).getPropertyValue('overflow-x') !== 'hidden') &&
      ($element.scrollWidth > $element.clientWidth)
    ))) {
      return;
    }
    if (deltaX > 0) {
      swipeEvent.emit(SwipeDirection.TO_RIGHT);
    } else {
      swipeEvent.emit(SwipeDirection.TO_LEFT);
    }
  } else if (yProportion > verticalMinYProportion && xProportion < verticalMaxProportion) {
    // Vertical swipe detected
    // Check for scrollable element
    if (isAnyParent(startTarget as HTMLElement, $element => (
      (window.getComputedStyle($element).getPropertyValue('overflow-y') !== 'hidden') &&
      ($element.scrollHeight > $element.clientHeight)
    ))) {
      return;
    }
    if (deltaY > 0) {
      swipeEvent.emit(SwipeDirection.TO_BOTTOM);
    } else {
      swipeEvent.emit(SwipeDirection.TO_TOP);
    }
  }
});
const swipeEventDebugLogger = new DebugLogger('Swipe Event');
swipeEvent.on(direction => {
  swipeEventDebugLogger.log(SwipeDirection[direction]);
});


code:
import { DebugLogger } from '../DebugLogger';
import { Event } from '../Event';

export enum ArrowKey {
  LEFT,
  UP,
  RIGHT,
  DOWN,
}

export const arrowKeyPressEvent = new Event<ArrowKey>();
export const escapeKeyPressEvent = new Event();
document.addEventListener('keydown', event => {
  if (event.repeat) {
    return;
  }
  switch (event.keyCode) {
    case 27:
      escapeKeyPressEvent.emit();
      break;
    case 37:
      arrowKeyPressEvent.emit(ArrowKey.LEFT);
      break;
    case 38:
      arrowKeyPressEvent.emit(ArrowKey.UP);
      break;
    case 39:
      arrowKeyPressEvent.emit(ArrowKey.RIGHT);
      break;
    case 40:
      arrowKeyPressEvent.emit(ArrowKey.DOWN);
      break;
  }
});

const arrowEventDebugLogger = new DebugLogger('Arrow Key Event');
arrowKeyPressEvent.on(arrowKey => {
  arrowEventDebugLogger.log(ArrowKey[arrowKey]);
});


code:
import { ItemDecoration, Menu } from '../Menu';
import { relativePathLookUpMap } from '../data/data';
import { pageHref } from '../data/hrefs';
import { shortNumber } from '../util/shortNumber';

export class AuthorsMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);
    // Cannot use data from authors.json because we need to list every single author
    const authors = new Map<string, number>();
    Array.from(relativePathLookUpMap.values()).forEach(chapterCtx => {
      const chars = chapterCtx.chapter.charsCount ?? 1;
      chapterCtx.chapter.authors.forEach(({ name }) => {
        if (authors.has(name)) {
          authors.set(name, authors.get(name)! + chars);
        } else {
          authors.set(name, chars);
        }
      });
    });
    Array.from(authors)
      .sort((a, b) => b[1] - a[1])
      .forEach(([name, chars]) => {
        const handle = this.addItem(name, {
          button: true,
          decoration: ItemDecoration.ICON_PERSON,
          link: pageHref(`author/${name}`),
        });
        handle.append(`[${shortNumber(chars)}]`);
      });
  }
}


code:
import { Folder } from '../../Data';
import { canChapterShown } from '../control/chapterControl';
import { data } from '../data/data';
import { chapterHref } from '../data/hrefs';
import { charCount, earlyAccess, showAbandonedChapters } from '../data/settings';
import { ItemDecoration, Menu } from '../Menu';
import { shortNumber } from '../util/shortNumber';
import { getDecorationForChapterType } from './ChaptersMenu';

export function isEmptyFolder(folder: Folder): boolean {
  return folder.children.every(child =>
    (child.type === 'folder')
      ? isEmptyFolder(child)
      : !canChapterShown(child));
}

export function isAbandonedFolder(folder: Folder): boolean {
  return folder.children.every(child =>
    (child.type === 'folder')
      ? isAbandonedFolder(child)
      : child.abandoned);
}

export class ChapterListingMenu extends Menu {
  public constructor(urlBase: string, folder?: Folder) {
    super(urlBase);
    if (folder === undefined) {
      folder = data.chapterTree;
    }
    for (const child of folder.children) {
      if (child.type === 'folder') {
        const handle = this.buildSubMenu(child.displayName, ChapterListingMenu, child)
          .setUrlSegment(child.displayName.replace(/ /g, '-'))
          .setDecoration(ItemDecoration.ICON_FOLDER)
          .setHidden(isEmptyFolder(child))
          .build();
        if (isAbandonedFolder(child)) {
          handle.prepend('[已弃坑]');
        }
        if (child.charsCount !== null && charCount.getValue()) {
          handle.append(`[${shortNumber(child.charsCount)}]`);
        }
      } else {
        if (child.hidden) {
          continue;
        }
        if (child.abandoned && !showAbandonedChapters.getValue()) {
          continue;
        }
        if (child.isEarlyAccess && !earlyAccess.getValue()) {
          continue;
        }
        const handle = this.addItem(child.displayName, {
          button: true,
          link: chapterHref(child.htmlRelativePath),
          decoration: getDecorationForChapterType(child.type),
        });
        if (child.abandoned) {
          handle.prepend('[已弃坑]');
        }
        if (folder.showIndex === true) {
          handle.prepend(`${child.displayIndex.join('.')}. `);
        }
        if (child.isEarlyAccess) {
          handle.prepend('[编写中]');
        }
        if (child.charsCount !== null && charCount.getValue()) {
          handle.append(`[${shortNumber(child.charsCount)}]`);
        }
      }
    }
  }
}


code:
import { NodeType } from '../../Data';
import { pageHref } from '../data/hrefs';
import { ItemDecoration, Menu } from '../Menu';
import { AuthorsMenu } from './AuthorsMenu';
import { ChapterListingMenu } from './ChapterListingMenu';
import { HistoryChaptersMenu } from './HistoryChaptersMenu';
import { LatestChaptersMenu } from './LatestChapterMenu';

export function getDecorationForChapterType(chapterType: NodeType) {
  switch (chapterType) {
    case 'Markdown': return ItemDecoration.ICON_FILE;
    case 'WTCD': return ItemDecoration.ICON_GAME;
  }
}

export class ChaptersMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);
    this.buildSubMenu('所有章节', ChapterListingMenu)
      .setDecoration(ItemDecoration.ICON_LIST).build();
    this.buildSubMenu('最新更新', LatestChaptersMenu)
      .setDecoration(ItemDecoration.ICON_CALENDER).build();
    this.buildSubMenu('阅读历史', HistoryChaptersMenu)
      .setDecoration(ItemDecoration.ICON_HISTORY).build();
    this.addItem('按标签检索', {
      button: true,
      link: pageHref('tag-search'),
      decoration: ItemDecoration.ICON_TAG,
    });
    this.buildSubMenu('按作者检索', AuthorsMenu)
      .setDecoration(ItemDecoration.ICON_PERSON).build();
  }
}


code:
import { ItemDecoration, Menu } from '../Menu';

export class ContactMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);
    this.addItem('Telegram 更新推送频道', {
      button: true,
      link: 'https://t.me/joinchat/AAAAAEpkRVwZ-3s5V3YHjA',
      decoration: ItemDecoration.ICON_LINK,
    });
    this.addItem('Telegram 讨论组', {
      button: true,
      link: 'https://t.me/joinchat/Dt8_WlJnmEwYNbjzlnLyNA',
      decoration: ItemDecoration.ICON_LINK,
    });
    this.addItem('GitLab Repo', {
      button: true,
      link: 'https://gitlab.com/SCLeo/wearable-technology',
      decoration: ItemDecoration.ICON_LINK,
    });
    this.addItem('GitHub Repo 遗址（被封了）', {
      button: true,
      link: 'https://github.com/SCLeoX/Wearable-Technology',
      decoration: ItemDecoration.ICON_LINK,
    });
    this.addItem('原始 Google Docs', {
      button: true,
      link: 'https://docs.google.com/document/d/1Pp5CtO8c77DnWGqbXg-3e7w9Q3t88P35FOl6iIJvMfo/edit?usp=sharing',
      decoration: ItemDecoration.ICON_LINK,
    });
  }
}


code:
import { relativePathLookUpMap } from '../data/data';
import { chapterHref } from '../data/hrefs';
import { getHistory } from '../data/readingProgress';
import { Menu } from '../Menu';
import { getDecorationForChapterType } from './ChaptersMenu';

export class HistoryChaptersMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);
    // This is very fast. There is no need to display any loading text.
    getHistory().then(entries => {
      let hasAny = false;
      entries.forEach(({ relativePath, progress }) => {
        const chapterCtx = relativePathLookUpMap.get(relativePath);
        if (chapterCtx === undefined) {
          return;
        }
        hasAny = true;
        const handle = this.addItem(chapterCtx.folder.displayName + ' > ' + chapterCtx.chapter.displayName, {
          button: true,
          decoration: getDecorationForChapterType(chapterCtx.chapter.type),
          link: chapterHref(chapterCtx.chapter.htmlRelativePath),
        });
        handle.prepend(`[${progress === 1 ? '完成' : `${Math.round(progress * 100)}%` }]`);
      });
      if (!hasAny) {
        this.addItem('阅读历史为空');
      }
    }, () => {
      this.addItem('记录阅读历史需要浏览器支持 IndexedDB。您的浏览器不支持 IndexedDB。请更新浏览器后再试。');
    });
  }
}


code:
import { relativePathLookUpMap } from '../data/data';
import { chapterHref } from '../data/hrefs';
import { ItemDecoration, Menu } from '../Menu';
import { formatTimeRelativeLong } from '../util/formatTime';
import { getDecorationForChapterType } from './ChaptersMenu';

export class LatestChaptersMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);
    let chapterCtxs = Array.from(relativePathLookUpMap.values());
    chapterCtxs = chapterCtxs.filter(chapterCtx => !chapterCtx.chapter.htmlRelativePath.includes('META'));
    chapterCtxs.sort((a, b) => b.chapter.creationTime - a.chapter.creationTime);
    chapterCtxs = chapterCtxs.slice(0, 20);
    chapterCtxs.forEach(chapterCtx => {
      const handle = this.addItem(chapterCtx.folder.displayName + ' > ' + chapterCtx.chapter.displayName, {
        button: true,
        decoration: getDecorationForChapterType(chapterCtx.chapter.type),
        link: chapterHref(chapterCtx.chapter.htmlRelativePath),
      });
      handle.prepend(`[${formatTimeRelativeLong(new Date(chapterCtx.chapter.creationTime * 1000))}]`);
    });
    this.addItem('查看所有章节', {
      button: true,
      decoration: ItemDecoration.ICON_LIST,
      link: '#/menu/章节选择/所有章节',
    });
  }
}


code:
const links: Array<{
  text: string,
  link: string,
}> = [
  { text: '艾利浩斯学院 图书馆', link: 'http://alhs.live' },
  { text: 'acted 咕咕喵的小说和小游戏', link: 'https://acted.gitlab.io/h3/' },
  { text: '琥珀的可穿戴科技番外', link: 'https://www.pixiv.net/novel/show.php?id=14995202' },
  { text: '千早快传', link: 'https://chihaya.cloud' },
];

import { ItemDecoration, Menu } from '../Menu';
export class LinkExchangeMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);
    links.sort(() => Math.random() - 0.5);
    links.forEach(({ text, link }) => this.addItem(text, {
      button: true,
      link,
      decoration: ItemDecoration.ICON_LINK,
    }));
  }
}


code:
import { pageHref } from '../data/hrefs';
import { Menu } from '../Menu';
import { ChaptersMenu } from './ChaptersMenu';
import { ContactMenu } from './ContactMenu';
import { LinkExchangeMenu } from './LinkExchangeMenu';
import { SettingsMenu } from './SettingsMenu';
import { StatsMenu } from './StatsMenu';
import { StyleMenu } from './StyleMenu';
import { ThanksMenu } from './ThanksMenu';

export class MainMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);
    this.container.classList.add('main');
    this.buildSubMenu('章节选择', ChaptersMenu).build();
    this.buildSubMenu('鸣谢列表', ThanksMenu).build();
    this.buildSubMenu('阅读器样式', StyleMenu).build();
    this.buildSubMenu('订阅/讨论组', ContactMenu).setUrlSegment('订阅及讨论组').build();
    this.addItem('最新评论', { button: true, link: pageHref('recent-comments') });
    this.buildSubMenu('友情链接', LinkExchangeMenu).build();
    this.addItem('源代码', { button: true, link: 'https://gitlab.com/SCLeo/wearable-technology' });
    this.buildSubMenu('设置', SettingsMenu).build();
    this.buildSubMenu('统计', StatsMenu).build();
  }
}


code:
import { stylePreviewArticle } from '../constant/stylePreviewArticle';
import { newContent, Side } from '../control/contentControl';
import { Layout } from '../control/layoutControl';
import { showMirrorSitesModal } from '../control/mirrorControl';
import { animation, BooleanSetting, chapterRecommendationCount, charCount, contactInfo, developerMode, earlyAccess, enablePushHelper, EnumSetting, fontFamily, gestureSwitchChapter, showAbandonedChapters, useComments, warning, wtcdGameQuickLoadConfirm } from '../data/settings';
import { ItemDecoration, ItemHandle, Menu } from '../Menu';
import { UserMenu } from './UserMenu';

export class EnumSettingMenu extends Menu {
  public constructor(urlBase: string, setting: EnumSetting, usePreview: boolean, callback: () => void) {
    super(urlBase, usePreview ? Layout.SIDE : Layout.OFF);
    let currentHandle: ItemHandle;
    if (usePreview) {
      const block = newContent(Side.RIGHT).addBlock();
      block.element.innerHTML = stylePreviewArticle;
    }
    setting.options.forEach((valueName, value) => {
      const handle = this.addItem(valueName, { button: true, decoration: ItemDecoration.SELECTABLE })
        .onClick(() => {
          currentHandle.setSelected(false);
          handle.setSelected(true);
          setting.setValue(value);
          currentHandle = handle;
          callback();
        });
      if (value === setting.getValue()) {
        currentHandle = handle;
        handle.setSelected(true);
      }
    });
  }
}

export class SettingsMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);

    this.buildSubMenu('评论身份管理', UserMenu).build();
    this.addItem('镜像站选择', { button: true }).onClick(() => {
      showMirrorSitesModal();
    });
    this.addBooleanSetting('NSFW 警告', warning);
    this.addBooleanSetting('使用动画', animation);
    this.addBooleanSetting('显示编写中章节', earlyAccess);
    this.addBooleanSetting('显示评论', useComments);
    this.addBooleanSetting('手势切换章节（仅限手机）', gestureSwitchChapter);
    this.addEnumSetting('字体', fontFamily, true);
    this.addBooleanSetting('显示每个章节的字数', charCount);
    this.addBooleanSetting('WTCD 游戏快速读取前确认', wtcdGameQuickLoadConfirm);
    this.addBooleanSetting('开发人员模式', developerMode);
    this.addBooleanSetting('文章末显示联系信息', contactInfo);
    this.addBooleanSetting('启用推送助手', enablePushHelper);
    this.addBooleanSetting('显示已弃坑章节', showAbandonedChapters);
    this.addEnumSetting('显示推荐章节数', chapterRecommendationCount);
  }
  public addBooleanSetting(label: string, setting: BooleanSetting) {
    const getText = (value: boolean) => `${label}：${value ? '开' : '关'}`;
    const handle = this.addItem(getText(setting.getValue()), { button: true })
      .onClick(() => {
        setting.toggle();
      });
    setting.event.on(newValue => {
      handle.setInnerText(getText(newValue));
    });
  }
  public addEnumSetting(label: string, setting: EnumSetting, usePreview?: true) {
    const getText = () => `${label}：${setting.getValueName()}`;
    const handle = this.buildSubMenu(label, EnumSettingMenu, setting, usePreview === true, () => {
      handle.setInnerText(getText());
    }).setDisplayName(getText()).build();
  }
}


code:
import { data } from '../data/data';
import { ItemDecoration, Menu } from '../Menu';
import { shortNumber } from '../util/shortNumber';

export class StatsKeywordsCountMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);
    this.addItem('添加其他关键词', {
      button: true,
      link: 'https://gitlab.com/SCLeo/wearable-technology/-/blob/master/src/builder/keywords.ts',
      decoration: ItemDecoration.ICON_LINK,
    });
    data.keywordsCount.forEach(([keyword, count]) => {
      this.addItem(`${keyword}：${shortNumber(count, 2)}`);
    });
  }
}


code:
import { data } from '../data/data';
import { pageHref } from '../data/hrefs';
import { ItemDecoration, Menu } from '../Menu';
import { shortNumber } from '../util/shortNumber';
import { StatsKeywordsCountMenu } from './StatsKeywordsCountMenu';

export class StatsMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);
    this.addItem('访问量统计', { button: true, link: pageHref('visit-count'), decoration: ItemDecoration.ICON_EQUALIZER });
    this.buildSubMenu('关键词统计', StatsKeywordsCountMenu)
      .setDecoration(ItemDecoration.ICON_EQUALIZER)
      .build();
    this.addItem(`总字数：${data.charsCount === null ? '不可用' : shortNumber(data.charsCount, 2)}`);
    this.addItem(`总段落数：${shortNumber(data.paragraphsCount, 2)}`);
  }
}


code:
import { stylePreviewArticle } from '../constant/stylePreviewArticle';
import { newContent, Side } from '../control/contentControl';
import { Layout } from '../control/layoutControl';
import { DebugLogger } from '../DebugLogger';
import { h } from '../hs';
import { ItemDecoration, Menu } from '../Menu';

interface StyleDef {
  readonly rectBgColor: string;
  readonly paperBgColor: string;
  readonly keyColor: [number, number, number];
  readonly linkColor: string;
  readonly linkHoverColor: string;
  readonly linkActiveColor: string;
  readonly commentColor: string;

  readonly keyIsDark: boolean;

  readonly contentBlockWarningColor: string;
}

class Style {
  public static currentlyEnabled: Style | null = null;
  private static themeColorMetaTag: HTMLMetaElement | null = null;
  private styleSheet: StyleSheet | null = null;
  private debugLogger: DebugLogger;
  public constructor(
    public readonly name: string,
    public readonly def: StyleDef,
  ) {
    this.debugLogger = new DebugLogger(`Style (${name})`);
  }
  private injectStyleSheet() {
    const $style = document.createElement('style');
    document.head.appendChild($style);
    const sheet = $style.sheet as CSSStyleSheet;
    sheet.disabled = true;

    const attemptInsertRule = (rule: string) => {
      try {
        sheet.insertRule(rule);
      } catch (error) {
        this.debugLogger.error(`Failed to inject rule "${rule}".`, error);
      }
    };

    const key = `rgb(${this.def.keyColor.join(',')})`;
    const keyAlpha = (alpha: number) => `rgba(${this.def.keyColor.join(',')},${alpha})`;

    // attemptInsertRule(`.container { color: ${key}; }`);
    // attemptInsertRule(`.menu { color: ${key}; }`);
    attemptInsertRule(`.menu .button:active::after { background-color: ${key}; }`);
    attemptInsertRule(`.button::after { background-color: ${key}; }`);
    attemptInsertRule(`body { background-color: ${this.def.paperBgColor}; }`);
    // attemptInsertRule(`.rect { background-color: ${this.def.rectBgColor}; }`);

    // attemptInsertRule(`.rect.reading>div { background-color: ${this.def.paperBgColor}; }`);
    // attemptInsertRule(`.rect.reading>div { color: ${key}; }`);
    // attemptInsertRule(`.rect.reading>.content a { color: ${this.def.linkColor}; }`);
    // attemptInsertRule(`.rect.reading>.content a:hover { color: ${this.def.linkHoverColor}; }`);
    // attemptInsertRule(`.rect.reading>.content a:active { color: ${this.def.linkActiveColor}; }`);
    // attemptInsertRule(`.rect.reading .early-access.content-block { background-color: ${this.def.contentBlockEarlyAccessColor}; }`);
    // attemptInsertRule(`.rect>.comments>div { background-color: ${this.def.commentColor}; }`);
    // attemptInsertRule(`@media (min-width: 901px) { ::-webkit-scrollbar-thumb { background-color: ${this.def.paperBgColor}; } }`);

    // attemptInsertRule(`.rect>.comments>.create-comment::before { background-color: ${key}; }`);

    attemptInsertRule(`:root { --comment-color:${this.def.commentColor}; }`);
    attemptInsertRule(`:root { --content-block-warning-color:${this.def.contentBlockWarningColor}; }`);

    attemptInsertRule(`:root { --rect-bg-color: ${this.def.rectBgColor}; }`);
    attemptInsertRule(`:root { --paper-bg-color: ${this.def.paperBgColor}; }`);
    attemptInsertRule(`:root { --link-color: ${this.def.linkColor}; }`);
    attemptInsertRule(`:root { --link-hover-color: ${this.def.linkHoverColor}; }`);
    attemptInsertRule(`:root { --link-active-color: ${this.def.linkActiveColor}; }`);

    attemptInsertRule(`:root { --key: ${key}; }`);
    attemptInsertRule(`:root { --key-opacity-01: ${keyAlpha(0.1)}; }`);
    attemptInsertRule(`:root { --key-opacity-014: ${keyAlpha(0.14)}; }`);
    attemptInsertRule(`:root { --key-opacity-015: ${keyAlpha(0.15)}; }`);
    attemptInsertRule(`:root { --key-opacity-023: ${keyAlpha(0.23)}; }`);
    attemptInsertRule(`:root { --key-opacity-05: ${keyAlpha(0.5)}; }`);
    attemptInsertRule(`:root { --key-opacity-07: ${keyAlpha(0.7)}; }`);
    attemptInsertRule(`:root { --key-opacity-007: ${keyAlpha(0.07)}; }`);
    attemptInsertRule(`:root { --key-opacity-004: ${keyAlpha(0.04)}; }`);

    attemptInsertRule(`:root { --button-color: ${this.def.commentColor}; }`);

    this.styleSheet = sheet;
  }
  public activate() {
    if (Style.currentlyEnabled !== null) {
      const currentlyEnabled = Style.currentlyEnabled;
      if (currentlyEnabled.styleSheet !== null) {
        currentlyEnabled.styleSheet.disabled = true;
      }
    }
    if (this.styleSheet === null) {
      this.injectStyleSheet();
    }
    this.styleSheet!.disabled = false;
    window.localStorage.setItem('style', this.name);

    if (Style.themeColorMetaTag === null) {
      Style.themeColorMetaTag = h('meta', {
        name: 'theme-color',
        content: this.def.paperBgColor,
      });
      document.head.appendChild(Style.themeColorMetaTag);
    } else {
      Style.themeColorMetaTag.content = this.def.paperBgColor;
    }

    Style.currentlyEnabled = this;
  }
}

const darkKeyLinkColors = {
  linkColor: '#00E',
  linkHoverColor: '#F00',
  linkActiveColor: '#00E',
};
const lightKeyLinkColors = {
  linkColor: '#7AB2E2',
  linkHoverColor: '#5A92C2',
  linkActiveColor: '#5A92C2',
};

const styles = [
  new Style('可穿戴科技（默认）', {
    rectBgColor: '#444',
    paperBgColor: '#333',
    keyColor: [221, 221, 221],
    ...lightKeyLinkColors,
    contentBlockWarningColor: '#E65100',
    commentColor: '#444',

    keyIsDark: false,
  }),
  new Style('白纸', {
    rectBgColor: '#EFEFED',
    paperBgColor: '#FFF',
    keyColor: [0, 0, 0],
    ...darkKeyLinkColors,
    contentBlockWarningColor: '#FFE082',
    commentColor: '#F5F5F5',

    keyIsDark: true,
  }),
  new Style('夜间', {
    rectBgColor: '#272B36',
    paperBgColor: '#38404D',
    keyColor: [221, 221, 221],
    ...lightKeyLinkColors,
    contentBlockWarningColor: '#E65100',
    commentColor: '#272B36',

    keyIsDark: false,
  }),
  new Style('羊皮纸', {
    rectBgColor: '#D8D4C9',
    paperBgColor: '#F8F4E9',
    keyColor: [85, 40, 48],
    ...darkKeyLinkColors,
    contentBlockWarningColor: '#FFE082',
    commentColor: '#F9EFD7',

    keyIsDark: true,
  }),
  new Style('巧克力', {
    rectBgColor: '#2E1C11',
    paperBgColor: '#3A2519',
    keyColor: [221, 175, 153],
    ...lightKeyLinkColors,
    contentBlockWarningColor: '#E65100',
    commentColor: '#2C1C11',

    keyIsDark: false,
  }),
];

export class StyleMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase, Layout.SIDE);
    const handles = styles.map(style => {
      const handle = this.addItem(style.name, { button: true, decoration: ItemDecoration.SELECTABLE })
        .onClick(() => {
          style.activate();
          handles.forEach(handle => handle.setSelected(false));
          handle.setSelected(true);
        });
      if (window.localStorage.getItem('style') === style.name) {
        handle.setSelected(true);
      }
      return handle;
    });

    const content = newContent(Side.RIGHT);
    const $div = content.addBlock().element;
    $div.innerHTML = stylePreviewArticle;
  }
}

const usedStyle = window.localStorage.getItem('style');
let flag = false;
for (const style of styles) {
  if (usedStyle === style.name) {
    style.activate();
    flag = true;
    break;
  }
}
if (!flag) {
  styles[0].activate();
}


code:
import { thanks } from '../constant/thanks';
import { ItemDecoration, Menu } from '../Menu';

export class ThanksMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);
    for (const person of thanks) {
      this.addItem(person.name, person.link === undefined
        ? {}
        : { button: true, link: person.link, decoration: ItemDecoration.ICON_LINK },
      );
    }
  }
}


code:
import { showLoginModal, showUpdateProfileModal } from '../control/userControl';
import { Menu } from '../Menu';

export class UserMenu extends Menu {
  public constructor(urlBase: string) {
    super(urlBase);
    this.addItem('身份令牌', { button: true }).onClick(() => {
      showLoginModal();
    });
    this.addItem('修改身份信息', { button: true }).onClick(() => {
      showUpdateProfileModal();
    });
  }
}


code:
import { AUTHOR_PAGE_AS, AUTHOR_PAGE_LINK, AUTHOR_PAGE_WORKS, AUTHOR_PAGE_WORKS_DESC, GO_TO_MENU } from '../constant/messages';
import { getRolePriority } from '../constant/rolePriorities';
import { setNavbarPath } from '../control/navbarControl';
import { Page } from '../control/pageControl';
import { authorInfoMap, relativePathLookUpMap } from '../data/data';
import { chapterHref } from '../data/hrefs';
import { h } from '../hs';
import { formatRelativePath } from '../util/formatRelativePath';
import { padName } from '../util/padName';
export const author: Page = {
  name: 'author',
  handler: (content, pagePath) => {
    const authorName = pagePath.get();
    setNavbarPath([
      { display: '作者列表', hash: '#/menu/章节选择/按作者检索' },
      { display: authorName, hash: null },
    ]);
    const author = authorInfoMap.get(authorName);
    const block = content.addBlock();
    block.element.appendChild(h('h1', authorName));
    if (author?.description !== undefined) {
      block.element.appendChild(h('p', author.description));
    }

    const roleChaptersMap = new Map<string, Array<string>>();

    for (const [relativePath, { chapter: { authors } }] of relativePathLookUpMap.entries()) {
      for (const { name, role } of authors) {
        if (name !== authorName) {
          continue;
        }
        if (!roleChaptersMap.has(role)) {
          roleChaptersMap.set(role, [relativePath]);
        } else {
          roleChaptersMap.get(role)!.push(relativePath);
        }
      }
    }

    block.element.appendChild(h('h2', AUTHOR_PAGE_WORKS));
    block.element.appendChild(h('p', AUTHOR_PAGE_WORKS_DESC.replace('$', padName(authorName))));

    const roleChaptersArray = Array.from(roleChaptersMap);
    roleChaptersArray.sort(([roleA, _], [roleB, __]) => getRolePriority(roleB) - getRolePriority(roleA));
    for (const [role, relativePaths] of roleChaptersArray) {
      block.element.appendChild(h('h4', AUTHOR_PAGE_AS + role));
      const $list = h('ul');
      for (const relativePath of relativePaths) {
        $list.appendChild(h('li',
          formatRelativePath(relativePath),
          '（',
          h('a.regular', {
            href: chapterHref(relativePath),
          }, AUTHOR_PAGE_LINK),
          '）',
        ));
      }
      block.element.appendChild($list);
    }

    block.element.appendChild(h('div.page-switcher', [
      h('a.to-menu', {
        href: window.location.pathname,
        onclick: (event: MouseEvent) => {
          event.preventDefault();
          history.back();
        },
      }, GO_TO_MENU),
    ]));
    return true;
  },
};


code:
import { COMMENTS_RECENT_SECTION } from '../constant/messages';
import { loadRecentComments } from '../control/commentsControl';
import { setNavbarPath } from '../control/navbarControl';
import { Page } from '../control/pageControl';

export const recentComments: Page = {
  name: 'recent-comments',
  handler: content => {
    setNavbarPath([{ display: COMMENTS_RECENT_SECTION, hash: null }]);
    loadRecentComments(content);
    return true;
  },
};


code:
import { COMMENTS_MENTION_NO_TOKEN_DESC, COMMENTS_MENTION_NO_TOKEN_TITLE, COMMENTS_MENTION_SECTION } from '../constant/messages';
import { loadRecentMentions } from '../control/commentsControl';
import { setNavbarPath } from '../control/navbarControl';
import { Page } from '../control/pageControl';
import { removeNewMentionLink } from '../control/userControl';
import { h } from '../hs';

export const recentMentions: Page = {
  name: 'recent-mentions',
  handler: content => {
    setNavbarPath([{ display: COMMENTS_MENTION_SECTION, hash: null }]);

    // ! In the name of spaghetti
    setTimeout(() => {
      removeNewMentionLink();
    }, 500);
    localStorage.setItem('lastCheckedMention', String(Date.now()));
    if (localStorage.getItem('token') === null) {
      content.addBlock({
        initElement: h('div',
          h('h1', COMMENTS_MENTION_NO_TOKEN_TITLE),
          h('p', COMMENTS_MENTION_NO_TOKEN_DESC),
        )
      });
    } else {
      loadRecentMentions(content, localStorage.getItem('token')!);
    }
    return true;
  },
};


code:
import { $e } from '../$e';
import { ContentBlock, ContentBlockSide, ContentBlockStyle } from '../control/contentControl';
import { Modal } from '../control/modalControl';
import { setNavbarPath } from '../control/navbarControl';
import { Page } from '../control/pageControl';
import { search, SearchInput } from '../control/searchControl';
import { data, tagAliasMap } from '../data/data';
import { chapterHref } from '../data/hrefs';
import { autoExpandTextArea } from '../util/DOM';
import { formatRelativePath } from '../util/formatRelativePath';
import { tagSpan } from '../util/tag';

function resolveAlias(maybeAliased: string) {
  let modifier: string = '';
  if ('+-'.includes(maybeAliased[0])) {
    modifier = maybeAliased[0];
    maybeAliased = maybeAliased.substr(1);
  }
  const split = maybeAliased.split('（');
  if (tagAliasMap.has(split[0].toLowerCase())) {
    split[0] = tagAliasMap.get(split[0].toLowerCase())!;
  }
  return modifier + split.join('（');
}

export const tagSearch: Page = {
  name: 'tag-search',
  handler: (content, pagePath) => {
    setNavbarPath([{ display: '标签搜索', hash: null }]);
    content.addBlock({
      initElement: (

绝大多数文章都没有标签！

请注意，本标签搜索系统只能搜索文章标签，不能搜索文章标题或内容。因为标签系统刚刚实装，绝大多数文章还没有标签。所有没有标签的文章将无法被搜索到。

如果你愿意协助打标签，请至此查看协助打标签的方式。

      ),
      style: ContentBlockStyle.WARNING,
    });
    content.appendLeftSideContainer();
    const $textarea = (<textarea className='general small'/>) as HTMLTextAreaElement;
    $textarea.value = pagePath.get().split('/').join('\n');
    const updateTextAreaSize = autoExpandTextArea($textarea, 40);
    function openTagsList() {
      const modal = new Modal(

标签列表

不是所有标签都有对应的文章。

            { data.tags.map(([tag]) => {
              let selected = $textarea.value.trim().split(/\s+/).includes(tag);
              const $tag = tagSpan(tag, selected);
              $tag.addEventListener('click', () => {
                if (selected) {
                  $textarea.value = $textarea.value.replace(new RegExp(`(^|\\s+)${tag}(?=$|\\s)`, 'g'), '');
                } else {
                  $textarea.value += `\n${tag}`;
                }
                $textarea.value = $textarea.value.trim();
                updateSearch($textarea.value);
                updateTextAreaSize();
                selected = !selected;
                $tag.classList.toggle('active', selected);
              });
              return $tag;
            }) }

 modal.close() }>关闭

 as HTMLDivElement
      );
      modal.setDismissible();
      modal.open();
    }
    let $errorList: HTMLElement | null = null;
    const searchBlock = content.addBlock({
      initElement: (

请输入要搜索的标签：

          { $textarea }

选择标签

      ),
      side: ContentBlockSide.LEFT,
    });
    const searchInfoBlock = content.addBlock({
      initElement: (

标签搜索

请在搜索输入框中输入需要查找的标签以开始搜索。

多个标签之间请用空格或换行分开。

在一个标签前添加减号可以排除这个标签。（例如：-含有男性）

在一个标签前添加加号可以强制需要这个标签。（例如：+贞操带）

你也可以点击搜索输入框下方的选择标签来快速选择。

      ),
    }).hide();
    const noResultsBlock = content.addBlock({
      initElement: (

抱歉，未找到任何匹配的文章

本标签搜索系统只能搜索文章标签，不能搜索文章标题或内容。请检查你所使用的搜索标签是否为正确的《可穿戴科技》文章标签。你可以点击搜索输入框下方的选择标签来列出所有可用的标签。

同时，因为标签系统刚刚实装，绝大多数文章还没有标签。所有没有标签的文章将无法被搜索到。如果你发现你喜欢的文章还没有标签，你可以选择帮助打标签。

      ),
    }).hide();
    const chapterBlocks: Array<ContentBlock> = [];
    let searchId = 0;
    async function updateSearch(searchText: string) {
      searchId++;
      const thisSearchId = searchId;
      searchText = searchText.trim();
      const searchTerms = searchText.split(/\s+/).map(resolveAlias).filter(searchTerm => searchTerm !== '');
      pagePath.set(searchTerms.join('/'));
      const searchInput: SearchInput = searchTerms.map(searchTerm => {
        if (searchTerm.startsWith('+')) {
          return { searchTag: searchTerm.substr(1), type: 'required' };
        }
        if (searchTerm.startsWith('-')) {
          return { searchTag: searchTerm.substr(1), type: 'excluded' };
        }
        return { searchTag: searchTerm, type: 'favored' };
      });
      const searchResult = await search(searchInput);
      if (thisSearchId !== searchId) {
        // New search launched.
        return;
      }
      const errors: Array<HTMLElement> = [];
      for (const { searchTag } of searchInput) {
        const match = searchTag.match(/^[+-]?(\S+?)(?:（(\S+?)）)?$/)!;
        const tagTuple = data.tags.find(([tag]) => tag === match[1]);
        if (tagTuple === undefined) {
          errors.push(
标签“{ match[1] }”不存在。
);
        } else if (match[2] !== undefined) {
          if (tagTuple[1] === null) {
            errors.push(
标签“{ match[1] }”不支持性别变种。
);
          } else if (!tagTuple[1].includes(match[2])) {
            errors.push(
标签“{ match[1] }”没有性别变种“{ match[2] }”。
);
          }
        }
      }
      $errorList?.remove();
      if (errors.length !== 0) {
        $errorList = (

{ ...errors }

        );
        searchBlock.element.append($errorList);
      }
      chapterBlocks.forEach(chapterBlock => chapterBlock.directRemove());
      chapterBlocks.length = 0;
      function showResultsFrom(startIndex: number) {
        const maxIndex = Math.min(searchResult.length - 1, startIndex + 9);
        for (let i = startIndex; i <= maxIndex; i++) {
          const { chapter, score, matchedTags } = searchResult[i];
          const chapterBlock = content.addBlock({
            initElement: (

                { (searchInput.length !== 1) && (

匹配率：{ (score / searchInput.length * 100).toFixed(0) }%

                ) }

                    { formatRelativePath(chapter.htmlRelativePath) }

{ chapter.authors.map(authorInfo => authorInfo.role + '：' + authorInfo.name).join('，') }

                { chapter.tags?.map(tag => {
                  const selected = matchedTags.includes(tag);
                  const $tag = tagSpan(tag, selected);
                  $tag.addEventListener('click', () => {
                    if (!selected) {
                      $textarea.value += `\n${tag}`;
                    } else {
                      let value = $textarea.value.replace(new RegExp(`(^|\\s+)\\+?${tag}(?=$|\\s)`, 'g'), '');
                      if (tag.includes('（')) {
                        value = value.replace(new RegExp(`(^|\\s+)\\+?${tag.split('（')[0]}(?=$|\\s)`, 'g'), '');
                      }
                      value = value.trim();
                      $textarea.value = value;
                    }
                    updateSearch($textarea.value);
                    updateTextAreaSize();
                  });
                  return $tag;
                }) }

            )
          });
          if (i === maxIndex && maxIndex < searchResult.length - 1) {
            chapterBlock.onEnteringView(() => showResultsFrom(i + 1));
          }
          chapterBlocks.push(chapterBlock);
        }
      }
      noResultsBlock.hide();
      searchInfoBlock.hide();
      if (searchResult.length === 0) {
        if (searchText === '') {
          searchInfoBlock.show();
        } else {
          noResultsBlock.show();
        }
      } else {
        showResultsFrom(0);
      }
    }
    updateSearch($textarea.value);
    $textarea.addEventListener('input', () => updateSearch($textarea.value));
    return true;
  },
};


code:
import { $e } from '../$e';
import { TagSpec, TagsSpec } from '../../TagsSpec';
import { Content } from '../control/contentControl';
import { confirm, Modal, showGenericError, showGenericSuccess } from '../control/modalControl';
import { setNavbarPath } from '../control/navbarControl';
import { Page } from '../control/pageControl';
import { dbKVGet, dbKVKey, dbKVSet } from '../data/db';
import { DebugLogger } from '../DebugLogger';
import { h } from '../hs';
import { autoExpandTextArea } from '../util/DOM';

const debugLogger = new DebugLogger('Tagging Tool');

const selectedKey = dbKVKey<Array<string>>('taggingToolSelected');
const showingVariantsKey = dbKVKey<Array<string>>('taggingToolShowingVariants');

function createTagVariant(tag: string, variant: string) {
  return `${tag}（${variant}）`;
}

export const taggingTool: Page = {
  name: 'tagging-tool',
  handler: (content: Content) => {
    setNavbarPath([{ display: '标签工具', hash: null }]);
    const loadingBlock = content.addBlock({
      initElement: h('div', '正在加载标签规格...')
    });
    (async () => {
      const tagsSpec: TagsSpec = JSON.parse(await fetch('./tagsSpec.json')
        .then(response => response.text()));
      const showingVariants = new Set(await dbKVGet(showingVariantsKey) ?? ['女']);
      loadingBlock.directRemove();
      /** Mapping from tag to tag spec */
      const tagSpecMap: Map<string, TagSpec> = new Map();
      const tagIndexMap: Map<string, number> = new Map();
      let index = 0;
      for (const tagSpec of tagsSpec) {
        tagSpecMap.set(tagSpec.tag, tagSpec);
        tagIndexMap.set(tagSpec.tag, index);
        index++;
      }
      const selectedTagVariants = new Set<string>();
      const mainBlock = content.addBlock({ initElement: h('.tagging-tool') as HTMLDivElement });
      const tagVariantElementsMap: Map<string, Array<{
        span: HTMLSpanElement,
        checkbox: HTMLInputElement,
      }>> = new Map();
      const variantsSet = new Set<string>();
      tagsSpec.forEach(tagSpec => tagSpec.variants?.forEach(variant => variantsSet.add(variant)));
      const variantMap: Map<string, Array<{
        tagVariantSpan: HTMLSpanElement,
        tag: string,
      }>> = new Map(Array.from(variantsSet).map(variant => [variant, []]));
      const prerequisiteLIs = new Array<HTMLLIElement>();
      const prerequisites: Array<{
        sourceTagVariant: string,
        requiresTagVariants: Array<string>,
        span: HTMLSpanElement,
        li: HTMLLIElement,
      }> = [];
      const $selectedOutputCode = h('code');
      function setSelected(tagVariant: string, value: boolean) {
        const tagVariantElements = tagVariantElementsMap.get(tagVariant);
        if (tagVariantElements === undefined) {
          debugLogger.warn('Unknown tag variant:', tagVariant);
          return false;
        }
        for (const { checkbox, span } of tagVariantElements) {
          checkbox.checked = value;
          span.classList.toggle('selected', value);
        }
        if (value) {
          selectedTagVariants.add(tagVariant);
        } else {
          selectedTagVariants.delete(tagVariant);
        }
        for (const prerequisiteLI of prerequisiteLIs) {
          prerequisiteLI.classList.remove('errored');
        }
        for (const tagVariantElements of tagVariantElementsMap.values()) {
          for (const { span } of tagVariantElements) {
            span.classList.remove('errored');
          }
        }
        let errored = false;
        for (const { sourceTagVariant, requiresTagVariants, span, li } of prerequisites) {
          if (selectedTagVariants.has(sourceTagVariant) && !requiresTagVariants.some(requiresTagVariant => selectedTagVariants.has(requiresTagVariant))) {
            errored = true;
            li.classList.add('errored');
            span.classList.add('errored');
          }
        }
        if (errored) {
          $selectedOutputCode.innerText = '有未满足的前置标签，输出已终止。缺失的前置标签已用红色标出。';
        } else if (selectedTagVariants.size === 0) {
          $selectedOutputCode.innerText = '请至少选择一个标签。';
        } else {
          $selectedOutputCode.innerText = Array
            .from(selectedTagVariants)
            .sort((a, b) => {
              const aTag = a.split('（')[0];
              const bTag = b.split('（')[0];
              const comparePriority = tagSpecMap.get(bTag)!.priority - tagSpecMap.get(aTag)!.priority;
              if (comparePriority !== 0) {
                return comparePriority;
              }
              return tagIndexMap.get(aTag)! - tagIndexMap.get(bTag)!;
            })
            .join('，');
        }
        dbKVSet(selectedKey, Array.from(selectedTagVariants)).catch(error => debugLogger.error(error));
        return true;
      }
      function setHovered(tagVariant: string, hovered: boolean) {
        const tagVariantElements = tagVariantElementsMap.get(tagVariant)!;
        for (const { span } of tagVariantElements) {
          span.classList.toggle('hovered', hovered);
        }
      }
      function createTagVariantElements(display: string, tag: string, variant?: string) {
        const tagVariant = (variant === undefined) ? tag : createTagVariant(tag, variant);
        const $checkbox = h('input', { type: 'checkbox' });
        let tagVariantElements = tagVariantElementsMap.get(tagVariant);
        if (tagVariantElements === undefined) {
          tagVariantElements = [];
          tagVariantElementsMap.set(tagVariant, tagVariantElements);
        }
        const $tagVariantSpan = h('span.tagging-tool-tag-variant', [
          $checkbox,
          display,
        ]) as HTMLSpanElement;
        if (variant !== undefined) {
          variantMap.get(variant)!.push({
            tag,
            tagVariantSpan: $tagVariantSpan,
          });
        }
        tagVariantElements.push({
          checkbox: $checkbox,
          span: $tagVariantSpan,
        });
        $tagVariantSpan.addEventListener('click', () => {
          if (selectedTagVariants.has(tagVariant)) {
            setSelected(tagVariant, false);
          } else {
            setSelected(tagVariant, true);
          }
        });
        $tagVariantSpan.addEventListener('mouseenter', () => {
          setHovered(tagVariant, true);
        });
        $tagVariantSpan.addEventListener('mouseleave', () => {
          setHovered(tagVariant, false);
        });
        return $tagVariantSpan;
      }
      function createTagElement(tagSpec: TagSpec, prerequisiteInfo?: { li: HTMLLIElement, sourceTagSpec: TagSpec }) {
        if (tagSpec.variants === null) {
          const $span = createTagVariantElements(tagSpec.tag, tagSpec.tag);
          if (prerequisiteInfo !== undefined) {
            const sourceTagSpec = prerequisiteInfo.sourceTagSpec;
            if (sourceTagSpec.variants === null) {
              prerequisites.push({
                sourceTagVariant: sourceTagSpec.tag,
                requiresTagVariants: [ tagSpec.tag ],
                li: prerequisiteInfo.li,
                span: $span,
              });
            } else {
              for (const sourceVariant of sourceTagSpec.variants) {
                prerequisites.push({
                  sourceTagVariant: createTagVariant(sourceTagSpec.tag, sourceVariant),
                  requiresTagVariants: [ tagSpec.tag ],
                  li: prerequisiteInfo.li,
                  span: $span,
                });
              }
            }
          }
          return $span;
        } else {
          const spans: Array<HTMLSpanElement> = [];
          for (const variant of tagSpec.variants) {
            const $span = createTagVariantElements(
              variant,
              tagSpec.tag,
              variant,
            );
            if (prerequisiteInfo !== undefined) {
              const sourceTagSpec = prerequisiteInfo.sourceTagSpec;
              if (sourceTagSpec.variants !== null) {
                prerequisites.push({
                  sourceTagVariant: createTagVariant(sourceTagSpec.tag, variant),
                  requiresTagVariants: [ createTagVariant(tagSpec.tag, variant) ],
                  li: prerequisiteInfo.li,
                  span: $span,
                });
              } else {
                prerequisites.push({
                  sourceTagVariant: sourceTagSpec.tag,
                  requiresTagVariants: tagSpec.variants.map(variant => createTagVariant(tagSpec.tag, variant)),
                  li: prerequisiteInfo.li,
                  span: $span,
                });
              }
            }
            spans.push($span);
          }
          return h(
            'span.tagging-tool-tag',
            [
              tagSpec.tag,
              h('span.no-available-variants', '无可用变种'),
              ...spans,
            ],
          );
        }
      }
      mainBlock.element.append(

标签工具
,

请注意，在《可穿戴科技》的标签系统中：

标签变种“男”是指非伪娘男性。如果小说中的角色是伪娘，请勿使用男性标签变种。

标签变种“机械”是指非人形机械，例如大型调教设备。如果小说中的角色是机器人，请用机器人的性别。

,

标签变种过滤
,

请选择在小说中出现的人物性别。
,

这些选项只会暂时隐藏对应的标签变种，在输出标签列表时，依然会输出在隐藏前所选择的标签。
,
      );
      const updateVariantFilter: Array<() => void> = [];
      for (const variant of variantsSet) {
        let selected = showingVariants.has(variant);
        const $checkbox = h('input', { type: 'checkbox' });
        const $span = h('span.tagging-tool-variant', [
          $checkbox,
          variant,
        ]);
        const update = () => {
          if (selected) {
            showingVariants.add(variant);
          } else {
            showingVariants.delete(variant);
          }
          dbKVSet(showingVariantsKey, Array.from(showingVariants));
          $checkbox.checked = selected;
          $span.classList.toggle('selected', selected);
          variantMap.get(variant)!.forEach(({ tagVariantSpan }) => {
            tagVariantSpan.classList.toggle('display-none', !selected);
            tagVariantSpan.parentElement!.classList.toggle(
              'has-available-variant',
              Array.from(tagVariantSpan.parentElement!.getElementsByClassName('tagging-tool-tag-variant'))
                .some($element => !$element.classList.contains('display-none')),
            );
          });
        };
        updateVariantFilter.push(update);
        $span.addEventListener('click', () => {
          selected = !selected;
          update();
        });
        mainBlock.element.append($span);
      }
      mainBlock.element.append(h('h2', '选择标签'));
      function requestResettingTags() {
        confirm('真的要重置所有选择的标签吗？', '这个操作不可撤销。', '确定重置', '不重置').then(result => {
          if (result) {
            for (const selectedTagVariant of selectedTagVariants) {
              setSelected(selectedTagVariant, false);
            }
          }
        });
      }
      function requestImportTags() {
        const $textarea = <textarea className='general large' /> as HTMLTextAreaElement;
        async function execute(replace: boolean) {
          const value = $textarea.value.trim();
          if (value === '') {
            showGenericError('请输入要导入的标签。');
            return;
          }
          if (replace) {
            if (!await confirm('确定导入', '这将重置目前已经选择的所有标签。你真的要继续吗？', '确定导入', '取消导入')) {
              return;
            }
            for (const selectedTagVariant of selectedTagVariants) {
              setSelected(selectedTagVariant, false);
            }
          }
          const failed: Array<string> = [];
          for (const tagVariant of value.split(/[\s，,]+/)) {
            if (!setSelected(tagVariant, true)) {
              failed.push(tagVariant);
            }
          }
          if (failed.length === 0) {
            await showGenericSuccess('导入成功');
            modal.close();
          } else {
            const warnModal = new Modal(

部分标签未能导入

以下为导入失败的标签：

{ failed.map(tagVariant =>
{ tagVariant }
) }

其余标签已导入完成。

 warnModal.close() }>关闭

 as HTMLDivElement
            );
            warnModal.setDismissible();
            warnModal.open();
          }
        }
        const modal = new Modal(

导入标签

请输入要导入的标签，不同标签之间请用空格或逗号分开：

            { $textarea }

 execute(true) }>替换当前标签

 execute(false) }>追加到当前标签

 modal.close() }>取消导入

 as HTMLDivElement
        );
        modal.setDismissible();
        modal.open();
        autoExpandTextArea($textarea);
      }
      mainBlock.element.append(

重置所有选择的标签

导入标签

      );
      mainBlock.element.append(
请选择小说所含有的标签：
);
      for (const tagSpec of tagsSpec) {
        mainBlock.element.append(h('p.tag-title', createTagElement(tagSpec)));
        const $descUL = h('ul');
        for (const descLine of tagSpec.desc) {
          const $descLineLI = h('li.desc-line') as HTMLLIElement;
          if (descLine.isPrerequisite) {
            prerequisiteLIs.push($descLineLI);
          }
          for (const segment of descLine.segments) {
            if (segment.type === 'text') {
              $descLineLI.append(segment.content);
            } else {
              if (descLine.isPrerequisite) {
                $descLineLI.append(createTagElement(
                  tagSpecMap.get(segment.tag)!, {
                  li: $descLineLI,
                  sourceTagSpec: tagSpec,
                })
                );
              } else {
                $descLineLI.append(createTagElement(tagSpecMap.get(segment.tag)!));
              }
            }
          }
          $descUL.append($descLineLI);
        }
        mainBlock.element.append($descUL);
      }
      mainBlock.element.append(h('h2', '输出'));
      mainBlock.element.append(h('p', '以下为选择的标签。'));
      mainBlock.element.append(h('pre.wrapping', $selectedOutputCode));
      mainBlock.element.append(

          如果想要为其他文章打标签，请点此重置已经选择了的标签。

      );
      updateVariantFilter.forEach(update => update());
      (await dbKVGet(selectedKey) ?? []).forEach(tagVariant => setSelected(tagVariant, true));
    })().catch(error => debugLogger.error(error));
    return true;
  },
};


code:
// !!! Super spaghetti code warning !!!

import { GO_TO_MENU, VISIT_COUNT_DESC_0, VISIT_COUNT_DESC_1, VISIT_COUNT_DESC_2, VISIT_COUNT_DISPLAYING, VISIT_COUNT_FAILED, VISIT_COUNT_LOADING, VISIT_COUNT_LOAD_MORE, VISIT_COUNT_LOAD_MORE_FAILED, VISIT_COUNT_LOAD_MORE_LOADING, VISIT_COUNT_TIMES, VISIT_COUNT_TIME_FRAME_ALL, VISIT_COUNT_TIME_FRAME_DAY, VISIT_COUNT_TIME_FRAME_HOUR, VISIT_COUNT_TIME_FRAME_MONTH, VISIT_COUNT_TIME_FRAME_WEEK, VISIT_COUNT_TIME_FRAME_YEAR, VISIT_COUNT_TITLE } from '../constant/messages';
import { backendUrl } from '../control/backendControl';
import { enterMenuMode } from '../control/menuControl';
import { setNavbarPath } from '../control/navbarControl';
import { Page } from '../control/pageControl';
import { AutoCache } from '../data/AutoCache';
import { relativePathLookUpMap } from '../data/data';
import { chapterHref } from '../data/hrefs';
import { DebugLogger } from '../DebugLogger';
import { h } from '../hs';
import { commaNumber } from '../util/commaNumber';
import { formatRelativePath } from '../util/formatRelativePath';
import { padName } from '../util/padName';
import { shortNumber } from '../util/shortNumber';

type TimeFrame = 'ALL' | 'HOUR' | 'DAY' | 'WEEK' | 'MONTH' | 'YEAR';

const timeFrames: Array<TimeFrame> = ['ALL', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'];

function getEndpoint(timeFrame: TimeFrame, page: number) {
  if (timeFrame === 'ALL') {
    return `${backendUrl}/stats/chapters/all?page=${page}`;
  } else {
    return `${backendUrl}/stats/chapters/recent?page=${page}&time_frame=${timeFrame}`;
  }
}

const debugLogger = new DebugLogger('Visit Count Logger');

function getTimeFrameText(timeFrame: TimeFrame) {
  switch (timeFrame) {
    case 'ALL': return VISIT_COUNT_TIME_FRAME_ALL;
    case 'HOUR': return VISIT_COUNT_TIME_FRAME_HOUR;
    case 'DAY': return VISIT_COUNT_TIME_FRAME_DAY;
    case 'WEEK': return VISIT_COUNT_TIME_FRAME_WEEK;
    case 'YEAR': return VISIT_COUNT_TIME_FRAME_YEAR;
    case 'MONTH': return VISIT_COUNT_TIME_FRAME_MONTH;
  }
}

function formatTitle(relativePath: string, visitCount: number) {
  return formatRelativePath(relativePath) + ': ' + shortNumber(visitCount, 2) + VISIT_COUNT_TIMES;
}

const visitCountCache = new AutoCache<string, any>(
  endpoint => fetch(endpoint).then(data => data.json()),
  new DebugLogger('Visit Count Cache'),
);

export const visitCount: Page = {
  name: 'visit-count',
  handler: content => {
    setNavbarPath([{ display: VISIT_COUNT_TITLE, hash: null }]);

    const block = content.addBlock();

    block.element.appendChild(h('h1', VISIT_COUNT_TITLE));
    block.element.appendChild(h('p', [
      VISIT_COUNT_DESC_0,
      h('a.regular', { href: '#META/隐私政策.html' }, VISIT_COUNT_DESC_1),
      VISIT_COUNT_DESC_2,
    ]));

    const $status = h('p');
    const $results = h('.visit-count-holder') as HTMLDivElement;
    const $loadMoreButton = h('div.rich') as HTMLDivElement;
    const $loadMoreContainer = h('.button-container.display-none', {
      style: { 'margin-top': '0.5em' },
    }, $loadMoreButton) as HTMLDivElement;

    // Used to determine whether the current request is still needed.
    let currentRequestId = 0;

    // Time frame to be used when clicking load more.
    let nextLoadingTimeFrame: TimeFrame = 'ALL';
    // Page to be load when clicking load more.
    let nextLoadingPage = 2;

    let maxVisits = 0;

    const load = (timeFrame: TimeFrame, page: number) => {
      const endpoint = getEndpoint(timeFrame, page);
      currentRequestId++;
      const requestId = currentRequestId;
      debugLogger.log(`Request ID ${requestId}: Loading visit count info from ${endpoint}.`);
      visitCountCache.get(endpoint).then(data => {
        if (content.isDestroyed || requestId !== currentRequestId) {
          debugLogger.log(`Request ID ${requestId}: Request completed, but the result is abandoned.`);
          return;
        }
        if (page === 1) {
          maxVisits = (data[0]?.visit_count) ?? 0;
          $loadMoreContainer.classList.remove('display-none');
        } else {
          $loadMoreButton.classList.remove('disabled');
        }
        $status.innerText = VISIT_COUNT_DISPLAYING.replace(/\$/g, padName(getTimeFrameText(timeFrame)));
        $loadMoreButton.innerText = VISIT_COUNT_LOAD_MORE;
        // If there is less than 50, stop showing load more button
        $loadMoreContainer.classList.toggle('display-none', data.length !== 50);
        for (const entry of data) {
          if (!relativePathLookUpMap.has(entry.relative_path)) {
            continue;
          }
          $results.appendChild(h(
            'a', {
            style: {
              'width': `${entry.visit_count / maxVisits * 100}%`,
            },
            title: commaNumber(entry.visit_count) + VISIT_COUNT_TIMES,
            href: chapterHref(entry.relative_path),
          }, formatTitle(entry.relative_path, entry.visit_count),
          ));
        }
        nextLoadingPage = page + 1;
      }).catch(error => {
        if (content.isDestroyed || requestId !== currentRequestId) {
          debugLogger.warn(`Request ID ${requestId}: Request failed, but the result is abandoned.`, error);
          return;
        }
        if (page === 1) {
          $status.innerText = VISIT_COUNT_FAILED;
        } else {
          $loadMoreButton.classList.remove('disabled');
          $loadMoreButton.innerText = VISIT_COUNT_LOAD_MORE_FAILED;
        }
      });
    };

    $loadMoreButton.addEventListener('click', () => {
      // Yes, I am doing it. I am using class list as my state keeper.
      if ($loadMoreButton.classList.contains('disabled')) {
        return;
      }
      $loadMoreButton.classList.add('disabled');
      $loadMoreButton.innerText = VISIT_COUNT_LOAD_MORE_LOADING;
      load(nextLoadingTimeFrame, nextLoadingPage);
    });

    const loadTimeFrame = (timeFrame: TimeFrame) => {
      $results.innerHTML = '';
      $status.innerText = VISIT_COUNT_LOADING;
      $loadMoreContainer.classList.add('display-none');
      nextLoadingTimeFrame = timeFrame;
      nextLoadingPage = 2;
      load(timeFrame, 1);
    };

    const ltfButtons: Array<HTMLDivElement> = [];

    /** Load time frame button */
    const createLtfButton = (text: string, timeFrame: TimeFrame) => {
      const $button = h('div.rich', {
        onclick: () => {
          for (const $ltfButton of ltfButtons) {
            $ltfButton.classList.toggle('selected', $ltfButton === $button);
          }
          loadTimeFrame(timeFrame);
        },
      }, text) as HTMLDivElement;
      if (timeFrame === 'ALL') {
        $button.classList.add('selected');
      }
      ltfButtons.push($button);
      return $button;
    };

    block.element.appendChild(h('.button-container', timeFrames.map(timeFrame => createLtfButton(
      getTimeFrameText(timeFrame),
      timeFrame,
    )))),
      block.element.appendChild($status);
    block.element.appendChild($results);
    block.element.appendChild($loadMoreContainer);

    block.element.appendChild(h('div.page-switcher', [
      h('a', {
        href: window.location.pathname,
        onclick: (event: MouseEvent) => {
          event.preventDefault();
          enterMenuMode();
        },
      }, GO_TO_MENU),
    ]));

    loadTimeFrame('ALL');

    return true;
  },
};


code:
import { h } from '../hs';
import { Page } from '../control/pageControl';
import { registerWithResultPopup, tokenItem } from '../control/userControl';
import { Modal, showGenericError, showGenericLoading } from '../control/modalControl';
import { backendUrl } from '../control/backendControl';
import { formatRelativePath } from '../util/formatRelativePath';
import { lastElement } from '../util/array';
import { relativePathLookUpMap } from '../data/data';
import { padName } from '../util/padName';
import { StringPersistentItem } from '../control/persistentItem';

/**
 * Convert a string to 32 bit hash
 * https://stackoverflow.com/a/47593316
 */
function xmur3(strSeed: string) {
  let h = 1779033703 ^ strSeed.length;
  for (let i = 0; i < strSeed.length; i++) {
    h = Math.imul(h ^ strSeed.charCodeAt(i), 3432918353),
      h = h << 13 | h >>> 19;
  }
  return () => {
    h = Math.imul(h ^ h >>> 16, 2246822507);
    h = Math.imul(h ^ h >>> 13, 3266489909);
    return (h ^= h >>> 16) >>> 0;
  };
}

/**
 * Create a seeded random number generator using the four passed in 32 bit
 * number as seeds.
 * https://stackoverflow.com/a/47593316
 *
 * @param a seed
 * @param b seed
 * @param c seed
 * @param d seed
 * @returns seeded random number generator
 */
function sfc32(a: number, b: number, c: number, d: number) {
  return () => {
    a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
    let t = (a + b) | 0;
    a = b ^ b >>> 9;
    b = c + (c << 3) | 0;
    c = (c << 21 | c >>> 11);
    d = d + 1 | 0;
    t = t + d | 0;
    c = c + t | 0;
    return (t >>> 0) / 4294967296;
  };
}

interface CandidateChapter {
  voteId: number;
  relativePath: string;
  overrideTitle?: string;
}

const candidates: Array<CandidateChapter> = [
  { voteId: 0, relativePath: '特别篇-公主殿下.html' },
  { voteId: 1, relativePath: '新能源.html' },
  { voteId: 2, relativePath: '长期贞操带实验第七次定期报告/第一幕.html', overrideTitle: '长期贞操带实验第七次定期报告' },
  { voteId: 3, relativePath: 'SBC-基金会档案/次元阴蒂环.html' },
  { voteId: 4, relativePath: 'SBC-基金会档案/淫纹打印机.html' },
  { voteId: 5, relativePath: 'SBC-基金会档案/173-号拘束衣.html' },
  { voteId: 6, relativePath: '长期贞操带实验定期报告（番外）.html' },
  { voteId: 7, relativePath: '疼痛实验记录/第一次调研报告.html', overrideTitle: '疼痛实验记录' },
  { voteId: 8, relativePath: '新能源：美妙的旅程.html' },
  { voteId: 9, relativePath: '城岭樱的游街仪式.html' },
  { voteId: 10, relativePath: '好朋友，不吵架.html' },
  { voteId: 11, relativePath: '梦野与千夏.html' },
  { voteId: 12, relativePath: '道具集/mWatch.html' },
  { voteId: 13, relativePath: '道具集/强制灌食器.html' },
  { voteId: 14, relativePath: '道具集/拟真穿戴式猫耳猫尾.html' },
  { voteId: 15, relativePath: '道具集/充电宝少女捕获器.html' },
  { voteId: 16, relativePath: '道具集/现代女仆管理系统.html' },
  { voteId: 17, relativePath: '道具集/可穿戴装置-γ-型介绍.html' },
  { voteId: 18, relativePath: '道具集/载人风筝.html' },
  { voteId: 19, relativePath: '道具集/强奸体验机.html' },
  { voteId: 20, relativePath: '道具集/未知录像文件.html' },
  { voteId: 21, relativePath: '道具集/智子.html' },
  { voteId: 22, relativePath: '道具集/强制高潮快感枪.html' },
  { voteId: 23, relativePath: '道具集/森宇奶茶杯.html' },
  { voteId: 24, relativePath: '道具集/拟真穿戴式猫耳猫尾-2.html' },
  { voteId: 25, relativePath: '道具集/可穿戴科技的其他版本-·-Flexible-(Episode-1).html' },
  { voteId: 26, relativePath: '道具集/家庭用多功能放置架套装.html' },
  { voteId: 27, relativePath: '道具集/使人快乐的-PS4.html' },
  { voteId: 28, relativePath: '道具集/家庭-SM-套件.html' },
  { voteId: 29, relativePath: '道具集/舔脚授权协议-0.0.html' },
  { voteId: 30, relativePath: '日常就是要惊险刺激——且淫乱.html'},
  { voteId: 31, relativePath: '所以说，甜蜜的反杀也是日常的一部分.html' },
];

export function isCandidate(relativePath: string) {
  return candidates.some(candidate => candidate.relativePath === relativePath);
}

let hasRandomized = false;
const seedItem = new StringPersistentItem('wtcupRandomSeed');
function ensureCandidatesAreRandomized() {
  if (hasRandomized) {
    return;
  }
  hasRandomized = true;
  // 原来打算用 token 当种子来决定随机顺序的，然后突然意识到这个 hash 和随机算法
  // 估计并不密码学安全。有一定可能性可以通过投票顺序倒推出来 token... 即使
  // 只用 token 的一部分也不行，因为早期的 token 是 hex，所以强度本身并不是非常
  // 高。如果能倒推出一部分就会显著降低安全度，所以现在干脆直接随机生成一个值当
  // 种子好了。当然这样的缺陷是不同设备同一个账号显示顺序会不一样，不过这个问题
  // 应该不大。
  // 另外，不使用用户名作为种子的原因是，其实现在不确定 initialization 是否完
  // 成，所以如果要用用户名这里还要等一个 initialization，特别麻烦。
  // 不使用 SHA256 的原因也是嫌麻烦。
  // const token = tokenItem.getValue()!;
  // const seedFn = xmur3(token.substr(8));
  if (!seedItem.exists()) {
    const seed = String(Math.random());
    seedItem.setValue(seed);
  }
  const seedFn = xmur3(seedItem.getValue()!);
  const rng = sfc32(seedFn(), seedFn(), seedFn(), seedFn());
  candidates.sort(() => rng() - 0.5);
}

let hasStarted = false;
const voteStatus: Array<number> = new Array(candidates.length).fill(0);

export const wtcupVote: Page = {
  name: 'wtcup-vote',
  handler: content => {
    const startVote = () => {
      if (!tokenItem.exists()) {
        const $nameInput = h('input');
        const $emailInput = h('input');
        const registerModal = new Modal(h('div', [
          h('h1', '请填写投票人信息'),
          h('p', '您的昵称或邮箱不会伴随投票结果公开。'),
          h('.input-group', [
            h('span', '昵称（必填）：'),
            $nameInput,
          ]),
          h('.input-group', [
            h('span', '邮箱（可选）：'),
            $emailInput,
          ]),
          h('.button-container', [
            h('div', {
              onclick: () => {
                const loadingModal = showGenericLoading();
                registerWithResultPopup(
                  $nameInput.value,
                  $emailInput.value === '' ? null : $emailInput.value
                ).then(success => {
                  if (success) {
                    registerModal.close();
                    initializeVotingBlocks();
                  }
                }).finally(() => loadingModal.close());
              }
            }, '确认'),
            h('div', {
              onclick: () => registerModal.close(),
            }, '取消'),
          ]),
        ]));
        registerModal.open();
      } else {
        const loadingModal = showGenericLoading('正在加载投票记录...');
        fetch(`${backendUrl}/event/getWtcupVotes`, {
          cache: 'no-cache',
          method: 'POST',
          headers: new Headers({
            'Content-Type': 'application/json'
          }),
          body: JSON.stringify({ token: tokenItem.getValue()! }),
        }).then(response => response.json()).then((votes: Array<{
          chapter_vote_id: number,
          rating: number,
        }>) => {
          for (const vote of votes) {
            voteStatus[vote.chapter_vote_id] = vote.rating;
          }
          initializeVotingBlocks();
        }, showGenericError).finally(() => loadingModal.close());
      }
    };
    const $welcomeBlock = h('div', [
      h('h1', '《可穿戴科技》第一届西塔杯评选投票'),
      h('p', [
        '欢迎参与《可穿戴科技》',
        h('a.regular', {
          href: '#META/第一届西塔杯评选.html',
        }, '第一届西塔杯'),
        '评选投票。您的投票将直接决定西塔杯的评选结果。'
      ]),
      h('p', '请点击下方按钮开始投票。'),
      h('.button-container', [
        h('div', {
          onclick: startVote,
        }, '开始投票'),
      ]),
    ]);
    const welcomeBlock = content.addBlock({
      initElement: $welcomeBlock,
    });
    if (hasStarted) {
      initializeVotingBlocks();
    }
    function initializeVotingBlocks() {
      hasStarted = true;
      ensureCandidatesAreRandomized();
      welcomeBlock.directRemove();
      content.addBlock({
        initElement: h('div', [
          h('h1', '投票方式'),
          h('p', '请点击星星对以下作品评分。所有作品默认处于“未评分”状态。处于“未评分”状态的评分项将不用作计算作品分数。点击星星左侧的“×”可将作品评分重置为“未评分”状态。'),
          h('p', '作品将以随机顺序出现。'),
          h('p', '您的投票会被实时保存。您可以随时回到这里修改所做出的评分。')
        ]),
      });
      for (const candidate of candidates) {
        const formattedRelativePath = formatRelativePath(candidate.relativePath);
        const title = candidate.overrideTitle ?? lastElement(formattedRelativePath.split(' > '));
        const $clear = h('.choice.clear', 'clear');
        const stars: Array<HTMLDivElement> = [];
        let hovering = -1;
        const updateDisplay = () => {
          const rating = voteStatus[candidate.voteId];
          $clear.classList.toggle('dimmed', rating === 0);
          stars.forEach(($star, index) => {
            if (rating === 0) {
              if (hovering !== -1) {
                $star.classList.toggle('dimmed', false);
                $star.classList.toggle('less-dimmed', true);
              } else {
                $star.classList.toggle('dimmed', true);
                $star.classList.toggle('less-dimmed', false);
              }
            } else {
              $star.classList.toggle('dimmed', false);
              $star.classList.toggle('less-dimmed', false);
            }

            $star.innerText = (index <= (hovering !== -1 ? hovering : (rating - 1))) ? 'star' : 'star_border';
          });
        };
        let lastConfirmed = voteStatus[candidate.voteId];
        let isSending = false;
        let waitingToSend = -1;
        const fetchSendVote = (rating: number) => {
          isSending = true;
          fetch(`${backendUrl}/event/voteWtcup`, {
            cache: 'no-cache',
            method: 'POST',
            headers: new Headers({
              'Content-Type': 'application/json'
            }),
            body: JSON.stringify({
              token: tokenItem.getValue()!,
              chapter_vote_id: candidate.voteId,
              rating,
            }),
          }).then(response => response.json()).then(() => {
            isSending = false;
            lastConfirmed = rating;
            if (waitingToSend !== -1) {
              fetchSendVote(waitingToSend);
              waitingToSend = -1;
            }
          }, () => {
            isSending = false;
            showGenericError(padName(title) + '的评分提交失败，请重试。');
            voteStatus[candidate.voteId] = lastConfirmed; // Revert
            updateDisplay();
            waitingToSend = -1;
          });
        };
        const castVote = (rating: number) => {
          if (isSending) {
            waitingToSend = rating;
          }
          voteStatus[candidate.voteId] = rating;
          updateDisplay();
          fetchSendVote(rating);
        };
        for (let i = 0; i < 5; i++) {
          let touchStart = 0;
          const onClick = () => {
            if (voteStatus[candidate.voteId] === i + 1) {
              castVote(0);
            } else {
              castVote(i + 1);
            }
          };
          stars.push(h('.choice.star', {
            onmouseenter: () => {
              hovering = i;
              updateDisplay();
            },
            onmouseleave: () => {
              hovering = -1;
              updateDisplay();
            },
            ontouchstart: () => {
              touchStart = Date.now();
            },
            ontouchend: (event: TouchEvent) => {
              if (event.cancelable) {
                event.preventDefault();
              }
              if (Date.now() - touchStart > 200) {
                return;
              }
              onClick();
            },
            onclick: onClick,
          }));
        }
        $clear.addEventListener('click', () => castVote(0));
        updateDisplay();
        content.addBlock({
          initElement: h('.wtcup-vote', [
            h('h2', title),
            h('p', relativePathLookUpMap.get(candidate.relativePath)!.chapter.authors
              .map(authorRole => authorRole.role + '：' + authorRole.name).join('，')),
            h('p', '请选择评分：'),
            h('.stars-container', [
              $clear,
              ...stars,
            ]),
            h('p', [
              '文章链接：',
              h('a.regular', {
                href: `#${candidate.relativePath}`,
              }, formattedRelativePath),
            ]),
          ]) as HTMLDivElement,
        });
      }
      content.addBlock({
        initElement: h('div', [
          h('h1', '投票完成！'),
          h('p', '以上就是所有参与评选的作品。感谢参与《可穿戴科技》第一届西塔杯评选投票，您的评分都已自动保存并上传至服务器。评选结果将在投票结束（北京时间 2020 年 12 月 31 日 23 时）后尽快发布。在投票结束前，您可以随时回到这里修改所做出的评分。'),
        ]),
      });
    }
    return true;
  },
};


code:
import { DebugLogger } from '../DebugLogger';
import { h } from '../hs';

export function id<T extends HTMLElement = HTMLDivElement>(id: string) {
  return document.getElementById(id) as T;
}

export function getTextNodes(parent: HTMLElement, initArray?: Array<Text>) {
  const textNodes: Array<Text> = initArray || [];
  let pointer: Node | null = parent.firstChild;
  while (pointer !== null) {
    if (pointer instanceof HTMLElement) {
      getTextNodes(pointer, textNodes);
    }
    if (pointer instanceof Text) {
      textNodes.push(pointer);
    }
    pointer = pointer.nextSibling;
  }
  return textNodes;
}

const selectNodeDebugLogger = new DebugLogger('Select Node');
export function selectNode(node: Node) {
  try {
    const selection = window.getSelection()!;
    const range = document.createRange();
    range.selectNodeContents(node);
    selection.removeAllRanges();
    selection.addRange(range);
  } catch (error) {
    selectNodeDebugLogger.log('Failed to select node: ', node, '; Error: ', error);
  }
}

export function isAnyParent(
  $element: HTMLElement | null,
  predicate: (node: HTMLElement) => boolean,
) {
  while ($element !== null) {
    if (predicate($element)) {
      return true;
    }
    $element = $element.parentElement;
  }
  return false;
}

export function insertAfter(
  $newElement: HTMLElement,
  $referencingElement: HTMLElement,
) {
  $referencingElement.parentElement!.insertBefore(
    $newElement,
    $referencingElement.nextSibling,
  );
}

export function insertAfterH1(
  $newElement: HTMLElement,
  $parent: HTMLElement,
) {
  const $first = $parent.firstChild;
  if (
    $first !== null &&
    $first instanceof HTMLHeadingElement &&
    $first.tagName.toLowerCase() === 'h1'
  ) {
    insertAfter($newElement, $first);
  } else {
    $parent.prepend($newElement);
  }
}

export function externalLink(text: string, href: string) {
  return h('a.regular', {
    target: '_blank',
    href,
    rel: 'noopener noreferrer',
  }, text);
}

export function linkButton(text: string, callback: () => void) {
  return h('a.regular', {
    href: '#',
    onclick: ((event: any) => {
      event.preventDefault();
      callback();
    }),
  }, text);
}

export function forceReflow($element: HTMLElement) {
  // tslint:disable-next-line:no-unused-expression
  $element.offsetHeight;
}

export function autoExpandTextArea($textarea: HTMLTextAreaElement, minHeightPx: number = 120) {
  function update() {
    $textarea.style.height = `1px`;
    $textarea.style.height = `${Math.max(minHeightPx, $textarea.scrollHeight)}px`;
  }
  $textarea.addEventListener('input', update, false);
  setTimeout(update, 1);
  return update;
}


code:
export function lastElement<T>(array: Array<T>): T {
  return array[array.length - 1];
}

export function range(fromInclusive: number, toExclusive: number, step: number = 1) {
  const result: Array<number> = [];
  for (let i = fromInclusive; i < toExclusive; i += step) {
    result.push(i);
  }
  return result;
}

export function produce<T>(count: number, producer: () => T) {
  const result: Array<T> = [];
  for (let i = 0; i < count; i++) {
    result.push(producer());
  }
  return result;
}


code:
export function commaNumber(num: number) {
  const segments: Array<string> = [];
  while (num >= 1000) {
    segments.push(String(num % 1000).padStart(3, '0'));
    num = Math.floor(num / 1000);
  }
  segments.push(String(num));
  segments.reverse();
  return segments.join(',');
}


code:
export function formatRelativePath(relativePath: string) {
  if (relativePath.endsWith('.html')) {
    relativePath = relativePath.substr(0, relativePath.length - '.html'.length);
  }
  relativePath = relativePath.replace(/\//g, ' > ');

  relativePath = relativePath.replace(/-/g, ' ');
  return relativePath;
}


code:
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = 365 / 12 * DAY; // Doesn't matter
const YEAR = 365 * DAY;

const MAX_RELATIVE_TIME = 7 * DAY;

export function formatTimeRelative(time: Date) {
  const relativeTime = Date.now() - time.getTime();
  if (relativeTime > MAX_RELATIVE_TIME) {
    return `${time.getFullYear()}/${time.getMonth() + 1}/${time.getDate()}`;
  }
  if (relativeTime > DAY) {
    return `${Math.floor(relativeTime / DAY)} 天前`;
  }
  if (relativeTime > HOUR) {
    return `${Math.floor(relativeTime / HOUR)} 小时前`;
  }
  if (relativeTime > MINUTE) {
    return `${Math.floor(relativeTime / MINUTE)} 分钟前`;
  }
  return `${Math.floor(relativeTime / SECOND)} 秒前`;
}

export function formatTimeRelativeLong(time: Date) {
  const relativeTime = Date.now() - time.getTime();
  if (relativeTime <= MAX_RELATIVE_TIME) {
    return formatTimeRelative(time);
  }
  if (relativeTime > YEAR) {
    return `${Math.floor(relativeTime / YEAR)} 年前`;
  }
  if (relativeTime > MONTH) {
    return `${Math.floor(relativeTime / MONTH)} 个月前`;
  }
  return `${Math.floor(relativeTime / DAY)} 天前`;
}

export function formatDurationCoarse(milliseconds: number) {
  if (milliseconds > 10 * DAY) {
    return ` ${Math.floor(milliseconds / DAY)} 天`;
  } else if (milliseconds > HOUR) {
    const day = Math.floor(milliseconds / DAY);
    const hour = Math.floor((milliseconds % DAY) / HOUR);
    if (hour === 0) {
      return ` ${day} 天`;
    } else {
      return ` ${day} 天 ${hour} 小时`;
    }
  } else {
    return '不到一小时';
  }
}

export function formatTimeSimple(time: Date) {
  return `${time.getFullYear()}-${time.getMonth() + 1}-${time.getDate()} ` +
    `${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}`;
}