// ==UserScript==
// @name 水源助手
// @namespace https://shuiyuan.sjtu.edu.cn/u/bluecat/summary
// @version 1.2.0
// @description 对上海交通大学水源论坛的各项功能进行优化
// @author bluecat, CCCC_David, pangbo
// @match https://shuiyuan.sjtu.edu.cn/*
// @grant none
// @license GPL-3.0-only
// @downloadURL none
// ==/UserScript==
(async () => {
'use strict';
// Script parameters.
const LOCAL_STORAGE_CONFIG_KEY = 'shuiyuanHelperConfig';
const SETTINGS_VIEW_FRAGMENT = '#shuiyuan_helper';
const FETCH_MAX_RETRIES = 15;
const EXP_BACKOFF_START = 1;
const EXP_BACKOFF_BASE = 1.2;
const RETORT_FETCH_INTERVAL = 300;
const RETORT_FETCH_MAX_ENTRIES = 20;
const INPUT_DEBOUNCE_INTERVAL = 300;
const MESSAGE_DISAPPEAR_TIMEOUT = 3000;
// Environment.
if (window.shuiyuanHelperLoaded) {
// eslint-disable-next-line no-console
console.log('Skipped loading Shuiyuan Helper as it has already been loaded.');
return;
}
window.shuiyuanHelperLoaded = true;
const IS_DESKTOP_VIEW = document.documentElement.classList.contains('desktop-view');
const IS_MOBILE_VIEW = document.documentElement.classList.contains('mobile-view');
const IS_DISCOURSE_VIEW = IS_DESKTOP_VIEW || IS_MOBILE_VIEW;
const IS_MOBILE_DEVICE = document.documentElement.classList.contains('mobile-device');
const isObject = (x) => typeof x === 'object' && !Array.isArray(x) && x !== null;
const saveConfig = (config) => localStorage.setItem(LOCAL_STORAGE_CONFIG_KEY, JSON.stringify(config));
const loadConfig = () => {
let config = null;
try {
config = JSON.parse(localStorage.getItem(LOCAL_STORAGE_CONFIG_KEY) ?? '{}');
} catch {
}
if (isObject(config)) {
return config;
}
saveConfig({});
return {};
};
let dataPreloaded = null;
const getDataPreloaded = () => {
if (dataPreloaded) {
return dataPreloaded;
}
dataPreloaded = JSON.parse(document.getElementById('data-preloaded').getAttribute('data-preloaded'));
return dataPreloaded;
};
const getSite = () => JSON.parse(getDataPreloaded().site);
const getSiteSettings = () => JSON.parse(getDataPreloaded().siteSettings);
const getCustomEmoji = () => JSON.parse(getDataPreloaded().customEmoji);
const getCurrentUsername = () => JSON.parse(getDataPreloaded().currentUser).username;
let retortLib = null;
const getRetortLib = () => {
if (retortLib) {
return retortLib;
}
try {
retortLib = window.require('discourse/plugins/retort/discourse/lib/retort').default;
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
return retortLib;
};
let emojiInfo = null;
const getEmojiInfo = () => {
if (emojiInfo) {
return emojiInfo;
}
emojiInfo = {};
try {
const statements = window.require('discourse/templates/components/emoji-group-sections').default(window.Discourse).parsedLayout.block.statements;
const i18n = window.require('I18n');
const result = [];
let lastSection = null;
let lastSectionEmojis = [];
const insertLastSection = () => {
if (lastSection === null) {
return;
}
result.push({
section: lastSection,
emojis: lastSectionEmojis,
});
lastSectionEmojis = [];
};
for (const statement of statements) {
const st1 = statement[1];
if (!Array.isArray(st1)) {
continue;
}
switch (st1[1]) {
case 'i18n':
// New section.
insertLastSection();
lastSection = i18n.t(st1[2][0]);
break;
case 'replace-emoji':
// New emoji.
lastSectionEmojis.push(st1[2][0].slice(1, -1));
break;
}
}
insertLastSection();
emojiInfo.standardEmojis = result;
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
// eslint-disable-next-line no-console
console.warn('Could not parse all standard emojis from source, falling back to built-in emojis.');
// cSpell: disable
emojiInfo.standardEmojis = [
{
section: '笑脸与情感',
emojis: ['grinning', 'smiley', 'smile', 'grin', 'laughing', 'sweat_smile', 'rofl', 'joy', 'slightly_smiling_face', 'upside_down_face', 'melting_face', 'wink', 'blush', 'innocent', 'smiling_face_with_three_hearts', 'heart_eyes', 'star_struck', 'kissing_heart', 'kissing', 'smiling_face', 'kissing_closed_eyes', 'kissing_smiling_eyes', 'smiling_face_with_tear', 'yum', 'stuck_out_tongue', 'stuck_out_tongue_winking_eye', 'crazy_face', 'stuck_out_tongue_closed_eyes', 'money_mouth_face', 'hugs', 'face_with_hand_over_mouth', 'face_with_open_eyes_and_hand_over_mouth', 'face_with_peeking_eye', 'shushing_face', 'thinking', 'saluting_face', 'zipper_mouth_face', 'face_with_raised_eyebrow', 'neutral_face', 'expressionless', 'no_mouth', 'dotted_line_face', 'face_in_clouds', 'smirk', 'unamused', 'roll_eyes', 'grimacing', 'face_exhaling', 'lying_face', 'relieved', 'pensive', 'sleepy', 'drooling_face', 'sleeping', 'mask', 'face_with_thermometer', 'face_with_head_bandage', 'nauseated_face', 'face_vomiting', 'sneezing_face', 'hot_face', 'cold_face', 'woozy_face', 'dizzy_face', 'face_with_spiral_eyes', 'exploding_head', 'cowboy_hat_face', 'partying_face', 'disguised_face', 'sunglasses', 'nerd_face', 'face_with_monocle', 'confused', 'face_with_diagonal_mouth', 'worried', 'slightly_frowning_face', 'frowning_face', 'open_mouth', 'hushed', 'astonished', 'flushed', 'pleading_face', 'face_holding_back_tears', 'frowning_with_open_mouth', 'anguished', 'fearful', 'cold_sweat', 'disappointed_relieved', 'cry', 'sob', 'scream', 'confounded', 'persevere', 'disappointed', 'sweat', 'weary', 'tired_face', 'yawning_face', 'triumph', 'rage', 'angry', 'face_with_symbols_over_mouth', 'smiling_imp', 'imp', 'skull', 'skull_and_crossbones', 'poop', 'clown_face', 'japanese_ogre', 'japanese_goblin', 'ghost', 'alien', 'space_invader', 'robot', 'smiley_cat', 'smile_cat', 'joy_cat', 'heart_eyes_cat', 'smirk_cat', 'kissing_cat', 'scream_cat', 'crying_cat_face', 'pouting_cat', 'see_no_evil', 'hear_no_evil', 'speak_no_evil', 'kiss', 'love_letter', 'cupid', 'gift_heart', 'sparkling_heart', 'heartpulse', 'heartbeat', 'revolving_hearts', 'two_hearts', 'heart_decoration', 'heavy_heart_exclamation', 'broken_heart', 'heart_on_fire', 'mending_heart', 'heart', 'orange_heart', 'yellow_heart', 'green_heart', 'blue_heart', 'purple_heart', 'brown_heart', 'black_heart', 'white_heart', '100', 'anger', 'boom', 'dizzy', 'sweat_drops', 'dash', 'hole', 'bomb', 'speech_balloon', 'eye_in_speech_bubble', 'left_speech_bubble', 'right_anger_bubble', 'thought_balloon', 'zzz'],
},
{
section: '人与身体',
emojis: ['wave', 'raised_back_of_hand', 'raised_hand_with_fingers_splayed', 'raised_hand', 'vulcan_salute', 'rightwards_hand', 'leftwards_hand', 'palm_down_hand', 'palm_up_hand', 'ok_hand', 'pinched_fingers', 'pinching_hand', 'v', 'crossed_fingers', 'hand_with_index_finger_and_thumb_crossed', 'love_you_gesture', 'metal', 'call_me_hand', 'point_left', 'point_right', 'point_up_2', 'fu', 'point_down', 'point_up', 'index_pointing_at_the_viewer', '+1', '-1', 'fist', 'facepunch', 'fist_left', 'fist_right', 'clap', 'raised_hands', 'heart_hands', 'open_hands', 'palms_up_together', 'handshake', 'pray', 'writing_hand', 'nail_care', 'selfie', 'muscle', 'mechanical_arm', 'mechanical_leg', 'leg', 'foot', 'ear', 'hear_with_hearing_aid', 'nose', 'brain', 'anatomical_heart', 'lungs', 'tooth', 'bone', 'eyes', 'eye', 'tongue', 'lips', 'biting_lip', 'baby', 'child', 'boy', 'girl', 'adult', 'blonde_man', 'man', 'bearded_person', 'man_beard', 'woman_beard', 'man_red_haired', 'man_curly_haired', 'man_white_haired', 'man_bald', 'woman', 'woman_red_haired', 'person_red_hair', 'woman_curly_haired', 'person_curly_hair', 'woman_white_haired', 'person_white_hair', 'woman_bald', 'person_bald', 'blonde_woman', 'man_blond_hair', 'older_adult', 'older_man', 'older_woman', 'person_frowning', 'frowning_man', 'frowning_woman', 'person_pouting', 'pouting_man', 'pouting_woman', 'person_gesturing_no', 'no_good_man', 'no_good_woman', 'person_gesturing_ok', 'ok_man', 'ok_woman', 'person_tipping_hand', 'tipping_hand_man', 'tipping_hand_woman', 'person_raising_hand', 'raising_hand_man', 'raising_hand_woman', 'deaf_person', 'deaf_man', 'deaf_woman', 'bowing_man', 'man_bowing', 'bowing_woman', 'person_facepalming', 'man_facepalming', 'woman_facepalming', 'person_shrugging', 'man_shrugging', 'woman_shrugging', 'health_worker', 'man_health_worker', 'woman_health_worker', 'student', 'man_student', 'woman_student', 'teacher', 'man_teacher', 'woman_teacher', 'judge', 'man_judge', 'woman_judge', 'farmer', 'man_farmer', 'woman_farmer', 'cook', 'man_cook', 'woman_cook', 'mechanic', 'man_mechanic', 'woman_mechanic', 'factory_worker', 'man_factory_worker', 'woman_factory_worker', 'office_worker', 'man_office_worker', 'woman_office_worker', 'scientist', 'man_scientist', 'woman_scientist', 'technologist', 'man_technologist', 'woman_technologist', 'singer', 'man_singer', 'woman_singer', 'artist', 'man_artist', 'woman_artist', 'pilot', 'man_pilot', 'woman_pilot', 'astronaut', 'man_astronaut', 'woman_astronaut', 'firefighter', 'man_firefighter', 'woman_firefighter', 'policeman', 'man_police_officer', 'policewoman', 'male_detective', 'man_detective', 'female_detective', 'guardsman', 'man_guard', 'guardswoman', 'ninja', 'construction_worker_man', 'man_construction_worker', 'construction_worker_woman', 'person_with_crown', 'prince', 'princess', 'man_with_turban', 'man_wearing_turban', 'woman_with_turban', 'man_with_gua_pi_mao', 'woman_with_headscarf', 'person_in_tuxedo', 'man_in_tuxedo', 'woman_in_tuxedo', 'bride_with_veil', 'man_with_veil', 'woman_with_veil', 'pregnant_woman', 'pregnant_man', 'pregnant_person', 'breast_feeding', 'woman_feeding_baby', 'man_feeding_baby', 'person_feeding_baby', 'angel', 'santa', 'mrs_claus', 'mx_claus', 'superhero', 'man_superhero', 'woman_superhero', 'supervillain', 'man_supervillain', 'woman_supervillain', 'mage', 'man_mage', 'woman_mage', 'fairy', 'man_fairy', 'woman_fairy', 'vampire', 'man_vampire', 'woman_vampire', 'merperson', 'merman', 'mermaid', 'elf', 'man_elf', 'woman_elf', 'genie', 'man_genie', 'woman_genie', 'zombie', 'man_zombie', 'woman_zombie', 'troll', 'person_getting_massage', 'massage_man', 'massage_woman', 'person_getting_haircut', 'haircut_man', 'haircut_woman', 'walking_man', 'man_walking', 'walking_woman', 'person_standing', 'man_standing', 'woman_standing', 'person_kneeling', 'man_kneeling', 'woman_kneeling', 'person_with_white_cane', 'man_with_probing_cane', 'woman_with_probing_cane', 'person_in_motorized_wheelchair', 'man_in_motorized_wheelchair', 'woman_in_motorized_wheelchair', 'person_in_manual_wheelchair', 'man_in_manual_wheelchair', 'woman_in_manual_wheelchair', 'running_man', 'man_running', 'running_woman', 'dancer', 'man_dancing', 'business_suit_levitating', 'dancing_women', 'dancing_men', 'women_with_bunny_ears', 'person_in_steamy_room', 'man_in_steamy_room', 'woman_in_steamy_room', 'person_climbing', 'man_climbing', 'woman_climbing', 'person_fencing', 'horse_racing', 'skier', 'snowboarder', 'golfing_man', 'man_golfing', 'golfing_woman', 'surfing_man', 'man_surfing', 'surfing_woman', 'rowing_man', 'man_rowing_boat', 'rowing_woman', 'swimming_man', 'man_swimming', 'swimming_woman', 'basketball_man', 'man_bouncing_ball', 'basketball_woman', 'weight_lifting_man', 'man_lifting_weights', 'weight_lifting_woman', 'biking_man', 'man_biking', 'biking_woman', 'mountain_biking_man', 'man_mountain_biking', 'mountain_biking_woman', 'person_cartwheeling', 'man_cartwheeling', 'woman_cartwheeling', 'people_wrestling', 'men_wrestling', 'women_wrestling', 'person_playing_water_polo', 'man_playing_water_polo', 'woman_playing_water_polo', 'person_playing_handball', 'man_playing_handball', 'woman_playing_handball', 'person_juggling', 'man_juggling', 'woman_juggling', 'person_in_lotus_position', 'man_in_lotus_position', 'woman_in_lotus_position', 'bath', 'sleeping_bed', 'people_holding_hands', 'two_women_holding_hands', 'couple', 'two_men_holding_hands', 'couplekiss_man_woman', 'kiss_woman_man', 'couplekiss_man_man', 'couplekiss_woman_woman', 'couple_with_heart', 'couple_with_heart_woman_man', 'couple_with_heart_man_man', 'couple_with_heart_woman_woman', 'family', 'family_man_woman_boy', 'family_man_woman_girl', 'family_man_woman_girl_boy', 'family_man_woman_boy_boy', 'family_man_woman_girl_girl', 'family_man_man_boy', 'family_man_man_girl', 'family_man_man_girl_boy', 'family_man_man_boy_boy', 'family_man_man_girl_girl', 'family_woman_woman_boy', 'family_woman_woman_girl', 'family_woman_woman_girl_boy', 'family_woman_woman_boy_boy', 'family_woman_woman_girl_girl', 'family_man_boy', 'family_man_boy_boy', 'family_man_girl', 'family_man_girl_boy', 'family_man_girl_girl', 'family_woman_boy', 'family_woman_boy_boy', 'family_woman_girl', 'family_woman_girl_boy', 'family_woman_girl_girl', 'speaking_head', 'bust_in_silhouette', 'busts_in_silhouette', 'people_hugging', 'footprints'],
},
{
section: '动物与自然',
emojis: ['monkey_face', 'monkey', 'gorilla', 'orangutan', 'dog', 'dog2', 'guide_dog', 'service_dog', 'poodle', 'wolf', 'fox_face', 'raccoon', 'cat', 'cat2', 'black_cat', 'lion', 'tiger', 'tiger2', 'leopard', 'horse', 'racehorse', 'unicorn', 'zebra', 'deer', 'bison', 'cow', 'ox', 'water_buffalo', 'cow2', 'pig', 'pig2', 'boar', 'pig_nose', 'ram', 'sheep', 'goat', 'dromedary_camel', 'camel', 'llama', 'giraffe', 'elephant', 'mammoth', 'rhinoceros', 'hippopotamus', 'mouse', 'mouse2', 'rat', 'hamster', 'rabbit', 'rabbit2', 'chipmunk', 'beaver', 'hedgehog', 'bat', 'bear', 'polar_bear', 'koala', 'panda_face', 'sloth', 'otter', 'skunk', 'kangaroo', 'badger', 'paw_prints', 'turkey', 'chicken', 'rooster', 'hatching_chick', 'baby_chick', 'hatched_chick', 'bird', 'penguin', 'dove', 'eagle', 'duck', 'swan', 'owl', 'dodo', 'feather', 'flamingo', 'peacock', 'parrot', 'frog', 'crocodile', 'turtle', 'lizard', 'snake', 'dragon_face', 'dragon', 'sauropod', 't_rex', 'whale', 'whale2', 'dolphin', 'seal', 'fish', 'tropical_fish', 'blowfish', 'shark', 'octopus', 'shell', 'coral', 'snail', 'butterfly', 'bug', 'ant', 'honeybee', 'beetle', 'lady_beetle', 'cricket', 'cockroach', 'spider', 'spider_web', 'scorpion', 'mosquito', 'fly', 'worm', 'microbe', 'bouquet', 'cherry_blossom', 'white_flower', 'lotus', 'rosette', 'rose', 'wilted_flower', 'hibiscus', 'sunflower', 'blossom', 'tulip', 'seedling', 'potted_plant', 'evergreen_tree', 'deciduous_tree', 'palm_tree', 'cactus', 'ear_of_rice', 'herb', 'shamrock', 'four_leaf_clover', 'maple_leaf', 'fallen_leaf', 'leaves', 'empty_nest', 'nest_with_eggs'],
},
{
section: '食物和饮料',
emojis: ['grapes', 'melon', 'watermelon', 'tangerine', 'lemon', 'banana', 'pineapple', 'mango', 'apple', 'green_apple', 'pear', 'peach', 'cherries', 'strawberry', 'blueberries', 'kiwi_fruit', 'tomato', 'olive', 'coconut', 'avocado', 'eggplant', 'potato', 'carrot', 'corn', 'hot_pepper', 'bell_pepper', 'cucumber', 'leafy_green', 'broccoli', 'garlic', 'onion', 'mushroom', 'peanuts', 'beans', 'chestnut', 'bread', 'croissant', 'baguette_bread', 'flatbread', 'pretzel', 'bagel', 'pancakes', 'waffle', 'cheese', 'meat_on_bone', 'poultry_leg', 'cut_of_meat', 'bacon', 'hamburger', 'fries', 'pizza', 'hotdog', 'sandwich', 'taco', 'burrito', 'tamale', 'stuffed_flatbread', 'falafel', 'egg', 'fried_egg', 'shallow_pan_of_food', 'stew', 'fondue', 'bowl_with_spoon', 'green_salad', 'popcorn', 'butter', 'salt', 'canned_food', 'bento', 'rice_cracker', 'rice_ball', 'rice', 'curry', 'ramen', 'spaghetti', 'sweet_potato', 'oden', 'sushi', 'fried_shrimp', 'fish_cake', 'moon_cake', 'dango', 'dumpling', 'fortune_cookie', 'takeout_box', 'crab', 'lobster', 'shrimp', 'squid', 'oyster', 'icecream', 'shaved_ice', 'ice_cream', 'doughnut', 'cookie', 'birthday', 'cake', 'cupcake', 'pie', 'chocolate_bar', 'candy', 'lollipop', 'custard', 'honey_pot', 'baby_bottle', 'milk_glass', 'coffee', 'teapot', 'tea', 'sake', 'champagne', 'wine_glass', 'cocktail', 'tropical_drink', 'beer', 'beers', 'clinking_glasses', 'tumbler_glass', 'pouring_liquid', 'cup_with_straw', 'bubble_tea', 'beverage_box', 'maté', 'ice_cube', 'chopsticks', 'plate_with_cutlery', 'fork_and_knife', 'spoon', 'hocho', 'jar', 'amphora'],
},
{
section: '旅行与地点',
emojis: ['earth_africa', 'earth_americas', 'earth_asia', 'globe_with_meridians', 'world_map', 'japan', 'compass', 'mountain_snow', 'mountain', 'volcano', 'mount_fuji', 'camping', 'beach_umbrella', 'desert', 'desert_island', 'national_park', 'stadium', 'classical_building', 'building_construction', 'brick', 'rock', 'wood', 'hut', 'houses', 'derelict_house', 'house', 'house_with_garden', 'office', 'post_office', 'european_post_office', 'hospital', 'bank', 'hotel', 'love_hotel', 'convenience_store', 'school', 'department_store', 'factory', 'japanese_castle', 'european_castle', 'wedding', 'tokyo_tower', 'statue_of_liberty', 'church', 'mosque', 'hindu_temple', 'synagogue', 'shinto_shrine', 'kaaba', 'fountain', 'tent', 'foggy', 'night_with_stars', 'cityscape', 'sunrise_over_mountains', 'sunrise', 'city_sunset', 'city_sunrise', 'bridge_at_night', 'hotsprings', 'carousel_horse', 'playground_slide', 'ferris_wheel', 'roller_coaster', 'barber', 'circus_tent', 'steam_locomotive', 'railway_car', 'bullettrain_side', 'bullettrain_front', 'train2', 'metro', 'light_rail', 'station', 'tram', 'monorail', 'mountain_railway', 'train', 'bus', 'oncoming_bus', 'trolleybus', 'minibus', 'ambulance', 'fire_engine', 'police_car', 'oncoming_police_car', 'taxi', 'oncoming_taxi', 'red_car', 'oncoming_automobile', 'blue_car', 'pickup_truck', 'truck', 'articulated_lorry', 'tractor', 'racing_car', 'motorcycle', 'motor_scooter', 'manual_wheelchair', 'motorized_wheelchair', 'auto_rickshaw', 'bike', 'kick_scooter', 'skateboard', 'roller_skate', 'busstop', 'motorway', 'railway_track', 'oil_drum', 'fuelpump', 'wheel', 'rotating_light', 'traffic_light', 'vertical_traffic_light', 'stop_sign', 'construction', 'anchor', 'ring_buoy', 'sailboat', 'canoe', 'speedboat', 'passenger_ship', 'ferry', 'motor_boat', 'ship', 'airplane', 'small_airplane', 'flight_departure', 'flight_arrival', 'parachute', 'seat', 'helicopter', 'suspension_railway', 'mountain_cableway', 'aerial_tramway', 'artificial_satellite', 'rocket', 'flying_saucer', 'bellhop_bell', 'luggage', 'hourglass', 'hourglass_flowing_sand', 'watch', 'alarm_clock', 'stopwatch', 'timer_clock', 'mantelpiece_clock', 'clock12', 'clock1230', 'clock1', 'clock130', 'clock2', 'clock230', 'clock3', 'clock330', 'clock4', 'clock430', 'clock5', 'clock530', 'clock6', 'clock630', 'clock7', 'clock730', 'clock8', 'clock830', 'clock9', 'clock930', 'clock10', 'clock1030', 'clock11', 'clock1130', 'new_moon', 'waxing_crescent_moon', 'first_quarter_moon', 'waxing_gibbous_moon', 'full_moon', 'waning_gibbous_moon', 'last_quarter_moon', 'waning_crescent_moon', 'crescent_moon', 'new_moon_with_face', 'first_quarter_moon_with_face', 'last_quarter_moon_with_face', 'thermometer', 'sunny', 'full_moon_with_face', 'sun_with_face', 'ringer_planet', 'star', 'star2', 'stars', 'milky_way', 'cloud', 'partly_sunny', 'cloud_with_lightning_and_rain', 'sun_behind_small_cloud', 'sun_behind_large_cloud', 'sun_behind_rain_cloud', 'cloud_with_rain', 'cloud_with_snow', 'cloud_with_lightning', 'tornado', 'fog', 'wind_face', 'cyclone', 'rainbow', 'closed_umbrella', 'open_umbrella', 'umbrella', 'parasol_on_ground', 'zap', 'snowflake', 'snowman_with_snow', 'snowman', 'comet', 'fire', 'droplet', 'ocean'],
},
{
section: '活动',
emojis: ['jack_o_lantern', 'christmas_tree', 'fireworks', 'sparkler', 'firecracker', 'sparkles', 'balloon', 'tada', 'confetti_ball', 'tanabata_tree', 'bamboo', 'dolls', 'flags', 'wind_chime', 'rice_scene', 'red_gift_envelope', 'ribbon', 'gift', 'reminder_ribbon', 'tickets', 'ticket', 'medal_military', 'trophy', 'medal_sports', '1st_place_medal', '2nd_place_medal', '3rd_place_medal', 'soccer', 'baseball', 'softball', 'basketball', 'volleyball', 'football', 'rugby_football', 'tennis', 'flying_disc', 'bowling', 'cricket_bat_and_ball', 'field_hockey', 'ice_hockey', 'lacrosse', 'ping_pong', 'badminton', 'boxing_glove', 'martial_arts_uniform', 'goal_net', 'golf', 'ice_skate', 'fishing_pole_and_fish', 'diving_mask', 'running_shirt_with_sash', 'ski', 'sled', 'curling_stone', 'dart', 'yo-yo', 'kite', '8ball', 'crystal_ball', 'magic_wand', 'nazar_amulet', 'hamsa', 'video_game', 'joystick', 'slot_machine', 'game_die', 'jigsaw', 'teddy_bear', 'piñata', 'mirror_ball', 'nesting_dolls', 'spades', 'hearts', 'diamonds', 'clubs', 'chess_pawn', 'black_joker', 'mahjong', 'flower_playing_cards', 'performing_arts', 'framed_picture', 'art', 'thread', 'sewing_needle', 'yarn', 'knot'],
},
{
section: '对象',
emojis: ['eyeglasses', 'dark_sunglasses', 'goggles', 'lab_coat', 'safety_vest', 'necktie', 'tshirt', 'jeans', 'scarf', 'gloves', 'coat', 'socks', 'dress', 'kimono', 'sari', 'one_piece_swimsuit', 'briefs', 'shorts', 'bikini', 'womans_clothes', 'purse', 'handbag', 'pouch', 'shopping', 'school_satchel', 'thong_sandal', 'mans_shoe', 'athletic_shoe', 'hiking_boot', 'flat_shoe', 'high_heel', 'sandal', 'ballet_shoes', 'boot', 'crown', 'womans_hat', 'tophat', 'mortar_board', 'billed_cap', 'military_helmet', 'rescue_worker_helmet', 'prayer_beads', 'lipstick', 'ring', 'gem', 'mute', 'speaker', 'sound', 'loud_sound', 'loudspeaker', 'mega', 'postal_horn', 'bell', 'no_bell', 'musical_score', 'musical_note', 'notes', 'studio_microphone', 'level_slider', 'control_knobs', 'microphone', 'headphones', 'radio', 'saxophone', 'accordion', 'guitar', 'musical_keyboard', 'trumpet', 'violin', 'banjo', 'drum', 'long_drum', 'iphone', 'calling', 'phone', 'telephone_receiver', 'pager', 'fax', 'battery', 'low_battery', 'electric_plug', 'computer', 'desktop_computer', 'printer', 'keyboard', 'computer_mouse', 'trackball', 'minidisc', 'floppy_disk', 'cd', 'dvd', 'abacus', 'movie_camera', 'film_strip', 'film_projector', 'clapper', 'tv', 'camera', 'camera_flash', 'video_camera', 'vhs', 'mag', 'mag_right', 'candle', 'bulb', 'flashlight', 'izakaya_lantern', 'diya_lamp', 'notebook_with_decorative_cover', 'closed_book', 'open_book', 'green_book', 'blue_book', 'orange_book', 'books', 'notebook', 'ledger', 'page_with_curl', 'scroll', 'page_facing_up', 'newspaper', 'newspaper_roll', 'bookmark_tabs', 'bookmark', 'label', 'moneybag', 'coin', 'yen', 'dollar', 'euro', 'pound', 'money_with_wings', 'credit_card', 'receipt', 'chart', 'email', 'e-mail', 'incoming_envelope', 'envelope_with_arrow', 'outbox_tray', 'inbox_tray', 'package', 'mailbox', 'mailbox_closed', 'mailbox_with_mail', 'mailbox_with_no_mail', 'postbox', 'ballot_box', 'pencil2', 'black_nib', 'fountain_pen', 'pen', 'paintbrush', 'crayon', 'memo', 'briefcase', 'file_folder', 'open_file_folder', 'card_index_dividers', 'date', 'calendar', 'spiral_notepad', 'spiral_calendar', 'card_index', 'chart_with_upwards_trend', 'chart_with_downwards_trend', 'bar_chart', 'clipboard', 'pushpin', 'round_pushpin', 'paperclip', 'paperclips', 'straight_ruler', 'triangular_ruler', 'scissors', 'card_file_box', 'file_cabinet', 'wastebasket', 'lock', 'unlock', 'lock_with_ink_pen', 'closed_lock_with_key', 'key', 'old_key', 'hammer', 'axe', 'pick', 'hammer_and_pick', 'hammer_and_wrench', 'dagger', 'crossed_swords', 'gun', 'boomerang', 'bow_and_arrow', 'shield', 'carpentry_saw', 'wrench', 'screwdriver', 'nut_and_bolt', 'gear', 'clamp', 'balance_scale', 'probing_cane', 'link', 'chains', 'hook', 'toolbox', 'magnet', 'ladder', 'alembic', 'test_tube', 'petri_dish', 'dna', 'microscope', 'telescope', 'satellite', 'syringe', 'drop_of_blood', 'pill', 'adhesive_bandage', 'crutch', 'stethoscope', 'xray', 'door', 'elevator', 'mirror', 'window', 'bed', 'couch_and_lamp', 'chair', 'toilet', 'plunger', 'shower', 'bathtub', 'mouse_trap', 'razor', 'lotion_bottle', 'safety_pin', 'broom', 'basket', 'roll_of_toilet_paper', 'bucket', 'soap', 'bubbles', 'toothbrush', 'sponge', 'fire_extinguisher', 'shopping_cart', 'smoking', 'coffin', 'headstone', 'funeral_urn', 'moyai', 'placard', 'identification_card'],
},
{
section: '符号',
emojis: ['atm', 'put_litter_in_its_place', 'potable_water', 'wheelchair', 'mens', 'womens', 'restroom', 'baby_symbol', 'wc', 'passport_control', 'customs', 'baggage_claim', 'left_luggage', 'warning', 'children_crossing', 'no_entry', 'no_entry_sign', 'no_bicycles', 'no_smoking', 'do_not_litter', 'non-potable_water', 'no_pedestrians', 'no_mobile_phones', 'underage', 'radioactive', 'biohazard', 'arrow_up', 'arrow_upper_right', 'arrow_right', 'arrow_lower_right', 'arrow_down', 'arrow_lower_left', 'arrow_left', 'arrow_upper_left', 'arrow_up_down', 'left_right_arrow', 'leftwards_arrow_with_hook', 'arrow_right_hook', 'arrow_heading_up', 'arrow_heading_down', 'arrows_clockwise', 'arrows_counterclockwise', 'back', 'end', 'on', 'soon', 'top', 'place_of_worship', 'atom_symbol', 'om', 'star_of_david', 'wheel_of_dharma', 'yin_yang', 'latin_cross', 'orthodox_cross', 'star_and_crescent', 'peace_symbol', 'menorah', 'six_pointed_star', 'aries', 'taurus', 'gemini', 'cancer', 'leo', 'virgo', 'libra', 'scorpius', 'sagittarius', 'capricorn', 'aquarius', 'pisces', 'ophiuchus', 'twisted_rightwards_arrows', 'repeat', 'repeat_one', 'arrow_forward', 'fast_forward', 'next_track_button', 'play_or_pause_button', 'arrow_backward', 'rewind', 'previous_track_button', 'arrow_up_small', 'arrow_double_up', 'arrow_down_small', 'arrow_double_down', 'pause_button', 'stop_button', 'record_button', 'eject_button', 'cinema', 'low_brightness', 'high_brightness', 'signal_strength', 'vibration_mode', 'mobile_phone_off', 'female_sign', 'male_sign', 'transgender_symbol', 'heavy_multiplication_x', 'heavy_plus_sign', 'heavy_minus_sign', 'heavy_division_sign', 'heavy_equals_sign', 'infinity', 'bangbang', 'interrobang', 'question', 'grey_question', 'grey_exclamation', 'exclamation', 'wavy_dash', 'currency_exchange', 'heavy_dollar_sign', 'medical_symbol', 'recycle', 'fleur_de_lis', 'trident', 'name_badge', 'beginner', 'o', 'white_check_mark', 'ballot_box_with_check', 'heavy_check_mark', 'x', 'negative_squared_cross_mark', 'curly_loop', 'loop', 'part_alternation_mark', 'eight_spoked_asterisk', 'eight_pointed_black_star', 'sparkle', 'copyright', 'registered', 'tm', 'hash', 'asterisk', 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'keycap_ten', 'capital_abcd', 'abcd', '1234', 'symbols', 'abc', 'a', 'ab', 'b', 'cl', 'cool', 'free', 'information_source', 'id', 'm', 'new', 'ng', 'o2', 'ok', 'parking', 'sos', 'up', 'vs', 'koko', 'sa', 'u6708', 'u6709', 'u6307', 'ideograph_advantage', 'u5272', 'u7121', 'u7981', 'accept', 'u7533', 'u5408', 'u7a7a', 'congratulations', 'secret', 'u55b6', 'u6e80', 'red_circle', 'orange_circle', 'yellow_circle', 'green_circle', 'large_blue_circle', 'purple_circle', 'brown_circle', 'black_circle', 'white_circle', 'red_square', 'orange_square', 'yellow_square', 'green_square', 'blue_square', 'purple_square', 'brown_square', 'black_large_square', 'white_large_square', 'black_medium_square', 'white_medium_square', 'black_medium_small_square', 'white_medium_small_square', 'black_small_square', 'white_small_square', 'large_orange_diamond', 'large_blue_diamond', 'small_orange_diamond', 'small_blue_diamond', 'small_red_triangle', 'small_red_triangle_down', 'diamond_shape_with_a_dot_inside', 'radio_button', 'white_square_button', 'black_square_button'],
},
{
section: '旗帜',
emojis: ['checkered_flag', 'triangular_flag_on_post', 'crossed_flags', 'black_flag', 'white_flag', 'rainbow_flag', 'transgender_flag', 'pirate_flag', 'ascension_island', 'andorra', 'united_arab_emirates', 'afghanistan', 'antigua_barbuda', 'anguilla', 'albania', 'armenia', 'angola', 'antarctica', 'argentina', 'american_samoa', 'austria', 'australia', 'aruba', 'aland_islands', 'azerbaijan', 'bosnia_herzegovina', 'barbados', 'bangladesh', 'belgium', 'burkina_faso', 'bulgaria', 'bahrain', 'burundi', 'benin', 'st_barthelemy', 'bermuda', 'brunei', 'bolivia', 'caribbean_netherlands', 'brazil', 'bahamas', 'bhutan', 'bouvet_island', 'botswana', 'belarus', 'belize', 'canada', 'cocos_islands', 'congo_kinshasa', 'central_african_republic', 'congo_brazzaville', 'switzerland', 'cote_divoire', 'cook_islands', 'chile', 'cameroon', 'cn', 'colombia', 'clipperton_island', 'costa_rica', 'cuba', 'cape_verde', 'curacao', 'christmas_island', 'cyprus', 'czech_republic', 'de', 'diego_garcia', 'djibouti', 'denmark', 'dominica', 'dominican_republic', 'algeria', 'ceuta_and_melilla', 'ecuador', 'estonia', 'egypt', 'western_sahara', 'eritrea', 'es', 'ethiopia', 'eu', 'finland', 'fiji', 'falkland_islands', 'micronesia', 'faroe_islands', 'fr', 'gabon', 'uk', 'grenada', 'georgia', 'french_guiana', 'guernsey', 'ghana', 'gibraltar', 'greenland', 'gambia', 'guinea', 'guadeloupe', 'equatorial_guinea', 'greece', 'south_georgia_south_sandwich_islands', 'guatemala', 'guam', 'guinea_bissau', 'guyana', 'hong_kong', 'heard_and_mc_donald_islands', 'honduras', 'croatia', 'haiti', 'hungary', 'canary_islands', 'indonesia', 'ireland', 'israel', 'isle_of_man', 'india', 'british_indian_ocean_territory', 'iraq', 'iran', 'iceland', 'it', 'jersey', 'jamaica', 'jordan', 'jp', 'kenya', 'kyrgyzstan', 'cambodia', 'kiribati', 'comoros', 'st_kitts_nevis', 'north_korea', 'kr', 'kuwait', 'cayman_islands', 'kazakhstan', 'laos', 'lebanon', 'st_lucia', 'liechtenstein', 'sri_lanka', 'liberia', 'lesotho', 'lithuania', 'luxembourg', 'latvia', 'libya', 'morocco', 'monaco', 'moldova', 'montenegro', 'st_martin', 'madagascar', 'marshall_islands', 'macedonia', 'mali', 'myanmar', 'mongolia', 'macau', 'northern_mariana_islands', 'martinique', 'mauritania', 'montserrat', 'malta', 'mauritius', 'maldives', 'malawi', 'mexico', 'malaysia', 'mozambique', 'namibia', 'new_caledonia', 'niger', 'norfolk_island', 'nigeria', 'nicaragua', 'netherlands', 'norway', 'nepal', 'nauru', 'niue', 'new_zealand', 'oman', 'panama', 'peru', 'french_polynesia', 'papua_new_guinea', 'philippines', 'pakistan', 'poland', 'st_pierre_miquelon', 'pitcairn_islands', 'puerto_rico', 'palestinian_territories', 'portugal', 'palau', 'paraguay', 'qatar', 'reunion', 'romania', 'serbia', 'ru', 'rwanda', 'saudi_arabia', 'solomon_islands', 'seychelles', 'sudan', 'sweden', 'singapore', 'st_helena', 'slovenia', 'svalbard_and_jan_mayen', 'slovakia', 'sierra_leone', 'san_marino', 'senegal', 'somalia', 'suriname', 'south_sudan', 'sao_tome_principe', 'el_salvador', 'sint_maarten', 'syria', 'swaziland', 'tristan_da_cunha', 'turks_caicos_islands', 'chad', 'french_southern_territories', 'togo', 'thailand', 'tajikistan', 'tokelau', 'timor_leste', 'turkmenistan', 'tunisia', 'tonga', 'tr', 'trinidad_tobago', 'tuvalu', 'taiwan', 'tanzania', 'ukraine', 'uganda', 'us_outlying_islands', 'united_nations', 'us', 'uruguay', 'uzbekistan', 'vatican_city', 'st_vincent_grenadines', 'venezuela', 'british_virgin_islands', 'us_virgin_islands', 'vietnam', 'vanuatu', 'wallis_futuna', 'samoa', 'kosovo', 'yemen', 'mayotte', 'south_africa', 'zambia', 'zimbabwe', 'england', 'scotland', 'wales'],
},
];
// cSpell: enable
}
try {
emojiInfo.customEmojis = getCustomEmoji();
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
// eslint-disable-next-line no-console
console.warn('Could not parse custom emojis from source, falling back to built-in emojis.');
// cSpell: disable
emojiInfo.customEmojis = [
{
name: 'bilibili',
url: '/uploads/default/original/3X/f/1/f100e7c58b4b6e2765c2ec92f22e5d3bbcc6446d.png',
group: 'default',
},
{
name: 'cpp',
url: '/uploads/default/original/3X/9/2/923b0d73cec8cd33b794daec1024a68f46f5df74.png',
group: 'default',
},
{
name: 'ecnu',
url: '/uploads/default/original/3X/b/2/b2ae26e2db563996ff0acdf6d8fc112c11ec01f7.png',
group: 'default',
},
{
name: 'fudan',
url: '/uploads/default/original/3X/c/6/c6dc2e44f2d6ce99a4d09dc81e299ad97ae18722.png',
group: 'default',
},
{
name: 'genshin_impact',
url: '/uploads/default/original/3X/a/8/a828c90941a7917b006e4940045ecc5560fad3b1.png',
group: 'default',
},
{
name: 'juban',
url: '/uploads/default/original/3X/9/a/9a805d43450cc0dad62df5194818633c1d51bff3.png',
group: 'default',
},
{
name: 'matlab',
url: '/uploads/default/original/3X/e/8/e8d530b0038c6bf2baacbf7d75fda3f095cda4f0.png',
group: 'default',
},
{
name: 'mcdonald',
url: '/uploads/default/original/3X/e/2/e276f3e19cd54bfcbe07f971583a4a623089134a.png',
group: 'default',
},
{
name: 'pku',
url: '/uploads/default/original/3X/d/5/d55dec31e0b689406acef03575cb9f4a6cd7feaa.png',
group: 'default',
},
{
name: 'python',
url: '/uploads/default/original/3X/6/0/6067b589f332bf2529cad40ab3fe4d2af96de9e5.png',
group: 'default',
},
{
name: 'sjtu',
url: '/uploads/default/original/3X/8/c/8cce985cdb66171f89856b3bda52c9303cf30a93.png',
group: 'default',
},
{
name: 'thu',
url: '/uploads/default/original/3X/7/3/73573337cad4f06abb98cb118cf984e526546d6b.png',
group: 'default',
},
{
name: 'tongji',
url: '/uploads/default/original/3X/2/8/2867e8b049c91b44162d7ac82de49d6d65e1489d.png',
group: 'default',
},
{
name: 'txmeeting',
url: '/uploads/default/original/3X/3/4/34230ea1f0218577a816009a7d34a93f566b0a53.png',
group: 'default',
},
{
name: 'ykst',
url: '/uploads/default/original/3X/2/b/2b8c224c799e1a084ab398a3fa51d99ffb04ca85.png',
group: 'default',
},
{
name: 'youtube',
url: '/uploads/default/original/3X/b/a/ba3daa80ccd82839177d8d0937bcbcde301f23bf.png',
group: 'default',
},
{
name: 'zju',
url: '/uploads/default/original/3X/f/1/f124d7dc68a94add161676d007153694e86d77e3.png',
group: 'default',
},
];
// cSpell: enable
}
emojiInfo.customEmojisMap = new Map(emojiInfo.customEmojis.map((ce) => [ce.name, ce.url]));
emojiInfo.allEmojiNames = [
...emojiInfo.standardEmojis.flatMap((s) => s.emojis),
...emojiInfo.customEmojisMap.keys(),
];
emojiInfo.allEmojiNamesSet = new Set(emojiInfo.allEmojiNames);
return emojiInfo;
};
let watchedWords = null;
const getWatchedWords = () => {
if (watchedWords) {
return watchedWords;
}
try {
watchedWords = getSite().watched_words_replace;
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
// eslint-disable-next-line no-console
console.warn('Could not parse watched words from source, falling back to built-in watched words.');
watchedWords = {
[`(?:\\W|^)(${decodeURIComponent('%E5%82%BB')}逼)(?=\\W|$)`]: '大笨蛋',
[`(?:\\W|^)(${decodeURIComponent('%E7%A5%9E')}经病)(?=\\W|$)`]: '小变态',
[`(?:\\W|^)(${decodeURIComponent('%E7%8E%8B')}八蛋)(?=\\W|$)`]: '小可爱',
[`(?:\\W|^)(${decodeURIComponent('%E5%86%9A')}家铲)(?=\\W|$)`]: '萌萌哒',
[`(?:\\W|^)(${decodeURIComponent('%E5%A6%88')}卖批)(?=\\W|$)`]: '不要啦',
[`(?:\\W|^)(${decodeURIComponent('%E5%AD%A4')}儿)(?=\\W|$)`]: '小宝贝',
};
}
return watchedWords;
};
// Utility functions.
// eslint-disable-next-line no-promise-executor-return
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const getElementsByTagNames = (el, tagNames) => (tagNames?.length ? [...el.querySelectorAll(tagNames.join(', '))] : []);
const getElementsByClassNames = (el, classNames) => (classNames?.length ? [...el.querySelectorAll(classNames.map((cl) => `.${cl}`).join(', '))] : []);
const dummyElementOfClasses = (classNames) => {
const el = document.createElement('dummy');
if (typeof classNames === 'string') {
el.className = classNames;
} else {
el.classList.add(...classNames);
}
return el;
};
const addGlobalStyle = (css) => {
const style = document.createElement('style');
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
return style;
};
const setIntersection = (set1, set2) => new Set([...set1].filter((x) => set2.has(x)));
const setDifference = (set1, set2) => new Set([...set1].filter((x) => !set2.has(x)));
const setsOverlap = (set1, set2) => setIntersection(set1, set2).size > 0;
const fixOptionObject = (value) => {
const obj = value ?? {};
if (typeof obj === 'object') {
return Array.isArray(obj) ? {} : obj;
}
return {enabled: Boolean(obj)};
};
const cleanUpKeys = (obj, validKeys) => {
for (const key of [...Object.keys(obj)]) {
if (!validKeys.has(key)) {
delete obj[key];
}
}
};
const parseStringOption = (value, defaultValue) => {
switch (typeof value) {
case 'string':
return value;
case 'number':
return value.toString();
default:
return defaultValue;
}
};
const promiseAllSettledLogErrors = async (promises) => {
const results = await Promise.allSettled(promises);
for (const r of results) {
if (r.status === 'rejected') {
// eslint-disable-next-line no-console
console.error(r.reason);
}
}
};
const debounce = (callback, interval) => {
let debounceTimeoutId;
return (...args) => {
clearTimeout(debounceTimeoutId);
debounceTimeoutId = setTimeout(() => callback(...args), interval);
};
};
const createFileDownload = (content, fileName, mimeType) => {
const downloadLink = document.createElement('a');
const blob = new Blob([content], {type: mimeType});
const blobURL = URL.createObjectURL(blob);
downloadLink.download = fileName;
downloadLink.href = blobURL;
downloadLink.click();
URL.revokeObjectURL(blobURL);
};
const getPreferencesRoute = () => `/u/${encodeURIComponent(getCurrentUsername())}/preferences/account`;
const getShuiyuanHelperRoute = () => `/u/${encodeURIComponent(getCurrentUsername())}/preferences/interface${SETTINGS_VIEW_FRAGMENT}`;
// Go to another Discourse route without top-level navigation.
const goToRoute = (path) => {
window.history.pushState({}, '', path);
let popStateCounter = 1;
const popStateListener = () => {
if (popStateCounter === 1) {
popStateCounter -= 1;
window.history.forward();
} else {
window.removeEventListener('popstate', popStateListener);
}
};
window.addEventListener('popstate', popStateListener);
window.history.back();
};
// Fetch wrapper with:
// - Discourse special headers.
// - Response status code check.
// - Limited exponential backoff retry upon 429 status code.
const discourseFetch = async (url, options) => {
let currentAttempt = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const response = await fetch(url, {
method: options?.method ?? 'GET',
headers: {
'Discourse-Present': 'true',
'Discourse-Logged-In': 'true',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content,
...options?.headers,
},
body: options?.body,
mode: 'same-origin',
credentials: 'include',
redirect: 'follow',
});
if (response.status === 429) {
currentAttempt += 1;
if (currentAttempt > FETCH_MAX_RETRIES) {
throw new Error('Max retries exceeded on 429 errors');
}
// eslint-disable-next-line no-await-in-loop
await sleep(1000 * EXP_BACKOFF_START * EXP_BACKOFF_BASE ** (currentAttempt - 1));
continue;
}
if (!response.ok) {
throw new Error(`${response.status}${response.statusText ? ` ${response.statusText}` : ''}`);
}
return response;
}
};
// Expose functions for debugging purposes.
window.shuiyuanHelperDebug = {
loadConfig,
saveConfig,
getDataPreloaded,
getSite,
getSiteSettings,
getCustomEmoji,
getCurrentUsername,
getEmojiInfo,
getWatchedWords,
discourseFetch,
};
// Implementation for each feature.
const ALL_FEATURES = [
{
id: 'post-qq-emoji',
description: '可在发帖内容中插入 QQ 表情',
enabledByDefault: true,
onInitialize: () => {
// Do not display QQ emoji entrypoint if we are not on a Discourse view (e.g. viewing an image).
if (!IS_DISCOURSE_VIEW) {
return;
}
const QQ_EMOJI_IDS = ['100', '101', '102', '103', '104', '105', '106', '107', '108', '109', '110', '111', '112', '113', '114', '115', '116', '117', '118', '119', '120', '121', '122', '123', '124', '125', '126', '127', '128', '129', '130', '131', '132', '133', '134', '135', '136', '137', '138', '139', '140', '141', '142', '143', '144', '145', '146', '147', '148', '149', '150', '151', '152', '153', '154', '155', '156', '157', '158', '159', '160', '161', '162', '163', '164', '165', '166', '167', '168', '169', '170', '171', '172', '173', '174', '175', '176', '177', '178', '179', '180', '181', '182', '183', '184', '185', '186', '187', '188', '189', '190', '191', '192', '193', '194', '195', '196', '197', '198', '199', '200', '201', '202', '203', '204', '205', '206', '207', '208', '209', '211', '212', '213', '214', '215', '216', '217', '218', '219', '220', '221', '222', '223', '224', '225', '226', '227', '228', '229', '230', '231', '232', '233', '234', '235', '236', '237', '238', '239', '240', '241', '242', '243', '244', '245', '246', '247', '248', '249', '250', '251', '252', '253', '254', '255', '256', '257', '258', '259', '260', '261', '262', '263', '264', '265', '266', '267', '268', '269', '270', '271', '272', '273', '274', '275', '276', '277', '278', '279', '280', '281', '282', '283', '284', '285', '286', '287', '288', '289', '290', '291', '292', '293', '294', '295', '296', '297', '298', '299', '300', '301', '302', '303', '304', '305', '306', '307', '308', '309', '310', '311', '312', '313', '314', '315', '316', '317', '318', '319', '320', '321', '322', '323', '324', '325', '326', '327', '328', '329', '330', '331', '332', '333', '334', '335', '336', '337', '338', '339', '340', '341', '342', '343', '344', '345', '346', '347', '348', '349', '350', '351', '352', '353', '354', '355', '356', '357', '358', '359', '360', '361', '362', '363', '364', '365', '366', '367', '368', '369', '370', '400', '401', '402', '403', '404', '405', '406', '407', '408', '409', '410', '500', '501', '502', '503', '504', '505', '506', '507', '508', '509', '510', '511', '512', '513', '514', '515', '516', '517', '518', '519', '520', '521', '522', '523', '524', '525', '526', '527', '528', '529', '530', '531', '532', '533', '534', '535', '536', '537', '538', '539', '540', '541', '542', '543', '544', '545', '546', '547', '548', '549', '550', '551', '552', '553', '554', '555', '556', '557', '558', '559', '560', '561', '562', '563', '564', '565', '566', '567', '568', '569', '570', '571', '572', '573', '574', '575', '576', '577', '578', '579', '580', '581', '582', '583', '584', '585', '586', '587', '588', '589', '590', '591', '592', '593', '594', '595', '596', '597', '598', '599', '600', '601', '602', '603', '604', '605', '606', '607', '608', '609', '610', '611', '612', '613', '614', '615', '616', '617', '618', '619', '620', '621', '622', '623', '624', '625', '626', '627', '628', '629', '630', '631', '632', '633', '634', '635', '636', '637', '638', '639', '640', '641', '642', '643', '644', '645', '646', '647', '648', '649', '650', '651', '652', '653', '654', '655', '656', '657', '658', '659', '660', '661', '662', '663', '664', '665', '666', '667', '668', '669', '670', '671', '672', '673', '674', '675', '676', '677', '678', '679', '680', '681', '682', '683', '684', '685', '686', '687', '688', '689', '690', '691', '692', '693', '694', '695', '696', '697', '698', '699', '700', '701', '702', '703', '704', '705', '706', '707', '708', '709', '710', '711', '712', '713', '714', '715', '716', '717', '718', '719', '720', '721', '722', '723', '724', '725', '726', '727', '728', '729', '730', '731', '732', '733', '734', '735', '736', '737', '738', '739', '740', '741', '742', '743', '744', '745', '746', '747', '748', '749', '750', '751', '752', '753', '754', '755', '756', '757', '758', '759', '760', '761', '762', '763', '764', '765', '766', '767', '768', '769', '770', '771', '772', '773', '774', '775', '776', '777', '829', '830', '831', '832', '833', '834', '835', '836', '837', '838', '839', '840', '841', '842', '843', '844', '845', '846', '847', '848', '849', '850', '851', '852', '853', '854', '855', '856', '857', '858', '859', '860', '861', '862', '863', '864', '865', '866', '867', '868', '869', '872', '877', '878', '879', '880', '881', '883', '884', '886', '887', '888', '889', '890', '891', '892', '893', '894', '895', '896', '897', '898', '899', '901', '902', '903', '904', '905', '906', '907', '908', '909', '910', '911', '924', '926', '927', '928', '929', '930', '931', '932', '933', '934', '935', '936', '937', '938', '939', '940', '941', '942', '943', '944', '945', '946', '947', '948', '949', '950', '951', '952', '953'];
const qqEmojiURL = (id) => `https://qzs.qq.com/qzone/em/e${id}.gif`;
const qqEmojiEntrypoint = document.createElement('img');
qqEmojiEntrypoint.src = qqEmojiURL(248);
qqEmojiEntrypoint.style.position = 'fixed';
qqEmojiEntrypoint.style.top = '15px';
qqEmojiEntrypoint.style.right = '15px';
qqEmojiEntrypoint.style.zIndex = '9999';
const qqEmojiDiv = document.createElement('div');
qqEmojiDiv.style.position = 'fixed';
qqEmojiDiv.style.top = '50px';
qqEmojiDiv.style.right = '15px';
qqEmojiDiv.style.zIndex = '9999';
qqEmojiDiv.style.width = '500px';
qqEmojiDiv.style.maxWidth = 'calc(90vw - 35px)';
qqEmojiDiv.style.height = '300px';
qqEmojiDiv.style.maxHeight = 'calc(90vh - 70px)';
qqEmojiDiv.style.padding = '10px';
qqEmojiDiv.style.overflowY = 'scroll';
qqEmojiDiv.style.background = '#eee';
qqEmojiDiv.style.display = 'none';
for (const id of QQ_EMOJI_IDS) {
const img = document.createElement('img');
img.src = qqEmojiURL(id);
img.classList.add('qq-emoji-item');
img.loading = 'lazy';
img.width = 24;
img.height = 24;
img.style.margin = '3px';
img.style.cursor = 'pointer';
qqEmojiDiv.appendChild(img);
}
qqEmojiEntrypoint.addEventListener('mouseenter', () => {
qqEmojiDiv.style.display = '';
});
qqEmojiEntrypoint.addEventListener('mouseleave', async () => {
await sleep(300);
if (!qqEmojiDiv.matches(':hover')) {
qqEmojiDiv.style.display = 'none';
}
});
qqEmojiDiv.addEventListener('mouseleave', () => {
qqEmojiDiv.style.display = 'none';
});
qqEmojiDiv.addEventListener('click', (e) => {
if (!e.target.matches?.('.qq-emoji-item')) {
return;
}
const editor = document.getElementsByClassName('d-editor-input')[0];
if (!editor) {
return;
}
const img = document.createElement('img');
img.src = e.target.src;
const insertHTML = img.outerHTML;
if (typeof editor.selectionStart === 'number' && typeof editor.selectionEnd === 'number') {
const startPos = editor.selectionStart;
const endPos = editor.selectionEnd;
let cursorPos = startPos;
const currentValue = editor.value;
editor.value = currentValue.substring(0, startPos) + insertHTML + currentValue.substring(endPos, currentValue.length);
cursorPos += insertHTML.length;
editor.selectionStart = editor.selectionEnd = cursorPos;
} else {
editor.value += insertHTML;
}
editor.focus();
editor.dispatchEvent(new Event('input', {
bubbles: true,
cancelable: true,
}));
});
document.body.prepend(qqEmojiEntrypoint);
document.body.prepend(qqEmojiDiv);
if (IS_MOBILE_VIEW) {
addGlobalStyle(`
.d-header .wrap {
padding-right: 50px;
}
`);
} else {
addGlobalStyle(`
@media (max-width: 1200px) {
.d-header .wrap {
padding-right: 50px;
}
}
`);
}
},
},
{
id: 'expand-who-liked',
description: '默认展开点赞人列表',
enabledByDefault: true,
matchClass: 'like-count',
onMatch: (element) => {
if (!element.shuiyuanHelperHandled) {
element.shuiyuanHelperHandled = true;
element.click();
}
},
},
{
id: 'show-liked-usernames',
description: '显示点赞人用户名',
enabledByDefault: true,
matchClass: 'who-liked',
onMatch: (element) => {
const updateWhoLikedItem = (el) => {
if (el.children.length > 1) {
return;
}
const span = document.createElement('span');
span.style.margin = '0 5px';
span.innerText = el.firstElementChild.getAttribute('title');
el.appendChild(span);
};
if (!element.shuiyuanHelperObserver) {
const whoLikedObserver = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.matches?.('a.trigger-user-card')) {
updateWhoLikedItem(node);
}
}
}
}
});
whoLikedObserver.observe(element, {
subtree: true,
childList: true,
});
element.shuiyuanHelperObserver = whoLikedObserver;
}
for (const el of element.querySelectorAll('a.trigger-user-card')) {
updateWhoLikedItem(el);
}
},
},
{
id: 'show-retort-users',
description: '显示所有贴表情人',
enabledByDefault: true,
matchClass: 'post-retort-container',
onMatch: (element) => {
const topicArea = element.closest('.topic-area');
const topicId = parseInt(topicArea.getAttribute('data-topic-id'), 10);
if (!topicArea.shuiyuanHelperData) {
topicArea.shuiyuanHelperData = {
retortFetchQueue: [],
retortFetchTimeoutID: null,
};
}
const shData = topicArea.shuiyuanHelperData;
const article = element.closest('article');
const postId = parseInt(article.getAttribute('data-post-id'), 10);
const isRetortButton = (node) => node.matches?.('button.post-retort');
const generateRetortItem = (retortContainer, retortButton, retortsMap) => {
const div = document.createElement('div');
div.shuiyuanHelperGenerated = true;
retortButton.shuiyuanHelperGenerated = true;
div.appendChild(retortButton);
const emoji = retortButton.firstElementChild.alt.slice(1, -1);
const usernames = retortsMap.get(emoji) || [];
for (const username of usernames) {
const userItem = document.createElement('a');
userItem.href = `/u/${encodeURIComponent(username)}`;
userItem.setAttribute('data-user-card', username);
userItem.innerText = username;
div.appendChild(userItem);
div.appendChild(document.createTextNode(';'));
}
retortContainer.appendChild(div);
};
const updateRetorts = (retortContainer, retorts) => {
const retortsMap = new Map(retorts.map((item) => [item.emoji, item.usernames]));
for (const el of [...retortContainer.children]) {
if (isRetortButton(el)) {
generateRetortItem(retortContainer, el, retortsMap);
} else if (el.matches('div') && el.shuiyuanHelperGenerated) {
generateRetortItem(retortContainer, el.firstElementChild, retortsMap);
el.remove();
} else {
// Unexpected element.
el.remove();
}
}
};
const processRetortUpdateRequests = async () => {
const postIdsToFetch = shData.retortFetchQueue.splice(0, RETORT_FETCH_MAX_ENTRIES);
const res = await discourseFetch(`/t/${topicId}/posts.json?${postIdsToFetch.map((id) => `post_ids%5B%5D=${id}`).join('&')}`);
const posts = (await res.json())?.post_stream?.posts || [];
for (const post of posts) {
const retorts = post?.retorts || [];
const retortContainer = topicArea.querySelector(`article[data-post-id="${post.id}"] .post-retort-container`);
if (!retortContainer) {
continue;
}
updateRetorts(retortContainer, retorts);
}
if (shData.retortFetchQueue.length > 0) {
shData.retortFetchTimeoutID = setTimeout(processRetortUpdateRequests, RETORT_FETCH_INTERVAL);
} else {
shData.retortFetchTimeoutID = null;
}
};
const enqueueRetortUpdateRequest = () => {
const queue = shData.retortFetchQueue;
if (!queue.includes(postId)) {
queue.push(postId);
}
if (shData.retortFetchTimeoutID === null) {
shData.retortFetchTimeoutID = setTimeout(processRetortUpdateRequests, RETORT_FETCH_INTERVAL);
}
};
const scheduleRetortUpdate = () => {
let retorts = null;
try {
retorts = getRetortLib().postFor(postId).retorts;
if (!Array.isArray(retorts)) {
throw new Error('in-memory retorts not an array');
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
retorts = null;
}
// If retorts are already available in memory, we do not need to fetch from backend, just update immediately.
if (retorts) {
updateRetorts(element, retorts);
return;
}
enqueueRetortUpdateRequest();
};
if (!element.shuiyuanHelperObserver) {
const retortObserver = new MutationObserver((mutationsList) => {
let shouldUpdateRetorts = false;
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (isRetortButton(node) && !node.shuiyuanHelperGenerated) {
shouldUpdateRetorts = true;
}
}
}
}
if (shouldUpdateRetorts) {
scheduleRetortUpdate();
}
});
retortObserver.observe(element, {
subtree: true,
childList: true,
});
element.shuiyuanHelperObserver = retortObserver;
}
if (element.firstElementChild) {
scheduleRetortUpdate();
}
},
},
{
id: 'emoji-picker-enhancements',
description: '表情面板增强功能',
enabledByDefault: true,
options: [
{
id: 'quick-access-emoji-list',
description: '我的快捷表情列表',
instructions: '输入以 | 字符分隔的表情名称列表,例如:horse|dragon',
type: 'string',
defaultValue: '',
},
],
matchClass: 'emoji-picker',
onMatch: (element, optionValues) => {
const {customEmojisMap, allEmojiNamesSet} = getEmojiInfo();
const getEmojiURL = (emoji) => {
try {
return window.require('discourse/lib/text').emojiUrlFor(emoji);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
return customEmojisMap.get(emoji) ?? `/images/emoji/google/${encodeURIComponent(emoji).replaceAll('%3A', '/')}.png`;
}
};
const createEmojiImg = (emoji) => {
const img = document.createElement('img');
img.src = getEmojiURL(emoji);
img.alt = emoji;
img.title = emoji;
img.classList.add('emoji');
img.width = '20';
img.height = '20';
img.loading = 'lazy';
return img;
};
const createEmojiSection = (sectionId, sectionName, emojis) => {
const newTitle = document.createElement('span');
newTitle.classList.add('title');
newTitle.innerText = sectionName;
const sectionHeader = document.createElement('div');
sectionHeader.classList.add('section-header');
sectionHeader.appendChild(newTitle);
const sectionGroup = document.createElement('div');
sectionGroup.classList.add('section-group');
for (const emoji of emojis) {
sectionGroup.appendChild(createEmojiImg(emoji));
}
const newSection = document.createElement('div');
newSection.classList.add('section');
newSection.setAttribute('data-section', sectionId);
newSection.appendChild(sectionHeader);
newSection.appendChild(sectionGroup);
return newSection;
};
const createEmojiSectionButton = (sectionId, emoji) => {
const button = document.createElement('button');
button.classList.add('btn', 'btn-default', 'category-button', 'emoji');
button.type = 'button';
button.setAttribute('data-section', sectionId);
button.appendChild(createEmojiImg(emoji));
button.addEventListener('click', () => {
const sectionDiv = element.querySelector(`div[data-section="${sectionId}"]`);
if (!sectionDiv) {
return;
}
sectionDiv.scrollIntoView(true);
});
return button;
};
const quickAccessList = optionValues['quick-access-emoji-list'].split('|')
.map((emoji) => emoji.trim()).filter(Boolean).filter((emoji) => allEmojiNamesSet.has(emoji));
const QUICK_ACCESS_SECTION_ID = 'shuiyuan_helper_quick_access_emoji_list';
if (quickAccessList.length && !element.querySelector(`div[data-section="${QUICK_ACCESS_SECTION_ID}"]`)) {
const smileysAndEmotionSection = element.querySelector('div[data-section="smileys_&_emotion"]');
const smileysAndEmotionSectionButton = element.querySelector('button[data-section="smileys_&_emotion"]');
if (smileysAndEmotionSection && smileysAndEmotionSectionButton) {
smileysAndEmotionSection.before(createEmojiSection(QUICK_ACCESS_SECTION_ID, '我的快捷表情', quickAccessList));
smileysAndEmotionSectionButton.before(createEmojiSectionButton(QUICK_ACCESS_SECTION_ID, 'pushpin'));
} else {
// eslint-disable-next-line no-console
console.error('quick access emoji list insertion point not found');
}
}
},
},
{
id: 'expand-hidden-posts',
description: '展开所有被举报隐藏的帖子',
enabledByDefault: false,
matchClass: 'expand-hidden',
onMatch: (element) => element.click(),
},
{
id: 'watched-words-detect-and-replace',
description: '对发帖内容中出现的和谐词显示警告,并提供尝试自动反和谐功能',
enabledByDefault: true,
matchClass: 'd-editor-input',
onMatch: (element) => {
if (element.shuiyuanHelperHandled) {
return;
}
const replaceWatchedWords = () => {
element.value = element.value
.replaceAll(decodeURIComponent('%E5%82%BB%E9%80%BC'), '傻逼')
.replaceAll(decodeURIComponent('%E7%A5%9E%E7%BB%8F%E7%97%85'), '神经病')
.replaceAll(decodeURIComponent('%E7%8E%8B%E5%85%AB%E8%9B%8B'), '王八蛋')
.replaceAll(decodeURIComponent('%E5%86%9A%E5%AE%B6%E9%93%B2'), '冚家铲')
.replaceAll(decodeURIComponent('%E5%A6%88%E5%8D%96%E6%89%B9'), '妈卖批')
.replaceAll(decodeURIComponent('%E5%AD%A4%E5%84%BF'), '孤儿');
element.dispatchEvent(new Event('input', {
bubbles: true,
cancelable: true,
}));
};
const checkWatchedWords = debounce(() => {
const composerActionTitle = document.getElementsByClassName('composer-action-title')[0];
if (!composerActionTitle) {
return;
}
document.getElementById('shuiyuan-helper-watched-words-warning')?.remove();
for (const [watchedWord, replacement] of Object.entries(getWatchedWords())) {
// cSpell: disable-next-line
if (new RegExp(watchedWord, 'imsu').test(element.value)) {
const warning = document.createElement('span');
warning.id = 'shuiyuan-helper-watched-words-warning';
warning.style.color = 'var(--danger-medium)';
warning.style.overflowX = 'auto';
warning.appendChild(document.createTextNode(`帖子匹配和谐词 ${watchedWord},可能被替换为 ${replacement},`));
const replaceLink = document.createElement('a');
replaceLink.innerText = '尝试自动反和谐';
replaceLink.addEventListener('click', replaceWatchedWords);
warning.appendChild(replaceLink);
composerActionTitle.appendChild(warning);
break;
}
}
}, INPUT_DEBOUNCE_INTERVAL);
element.shuiyuanHelperHandled = true;
element.addEventListener('input', checkWatchedWords);
checkWatchedWords();
},
},
{
id: 'remove-spoiler-blurred',
description: '去除 Spoiler 模糊',
enabledByDefault: true,
matchClass: 'spoiler-blurred',
onMatch: (element) => element.classList.remove('spoiler-blurred'),
},
{
id: 'settings-view',
description: '水源助手设置面板',
enabledByDefault: true,
hideFromSettings: true,
matchClass: 'preferences-nav',
onMatch: (element) => {
if (element.shuiyuanHelperHandled) {
return;
}
const createInstructionsDiv = (instructions) => {
const instructionsDiv = document.createElement('div');
instructionsDiv.classList.add('instructions');
instructionsDiv.textContent = instructions;
if (IS_DESKTOP_VIEW) {
instructionsDiv.style.marginBottom = '0';
}
return instructionsDiv;
};
const createCheckboxDiv = (description, instructions, checked, optionControls, clickHandler) => {
const input = document.createElement('input');
input.classList.add('ember-checkbox', 'ember-view');
input.type = 'checkbox';
input.checked = Boolean(checked);
input.addEventListener('click', clickHandler);
const label = document.createElement('label');
label.classList.add('checkbox-label');
label.appendChild(input);
label.appendChild(document.createTextNode(description));
const div = document.createElement('div');
div.classList.add('controls', 'ember-view');
div.appendChild(label);
if (instructions) {
label.style.marginBottom = '0';
div.appendChild(createInstructionsDiv(instructions));
}
if (optionControls) {
div.style.paddingLeft = '2em';
optionControls.push(input);
}
return div;
};
const createTextInputDiv = (description, instructions, value, optionControls, inputHandler) => {
const input = document.createElement('input');
input.classList.add('input-xxlarge', 'ember-text-field', 'ember-view');
input.type = 'text';
input.value = value;
input.addEventListener('input', debounce(inputHandler, INPUT_DEBOUNCE_INTERVAL));
const inputDiv = document.createElement('div');
inputDiv.appendChild(input);
const label = document.createElement('label');
label.textContent = description;
const div = document.createElement('div');
div.classList.add('controls', 'ember-view');
div.appendChild(label);
div.appendChild(inputDiv);
if (instructions) {
div.appendChild(createInstructionsDiv(instructions));
}
if (optionControls) {
div.style.paddingLeft = '2em';
optionControls.push(input);
}
return div;
};
const createButton = (text, isRed, clickHandler) => {
const button = document.createElement('button');
button.classList.add('btn', 'btn-text', 'ember-view', isRed ? 'btn-danger' : 'btn-primary');
button.type = 'button';
button.style.marginRight = '10px';
const span = document.createElement('span');
span.classList.add('d-button-label');
span.textContent = text;
button.appendChild(span);
button.addEventListener('click', clickHandler);
return button;
};
const isLiMatchingCurrentPath = (li) => new URL(li.firstElementChild.href).pathname.toLowerCase() === window.location.pathname.toLowerCase();
const liContainer = IS_MOBILE_VIEW ? element.querySelector('ul.drop') : element;
const existingLis = [...liContainer.getElementsByTagName('li')];
const shuiyuanHelperLink = document.createElement('a');
shuiyuanHelperLink.id = 'nav-shuiyuan-helper';
shuiyuanHelperLink.classList.add('ember-view');
shuiyuanHelperLink.innerText = '水源助手';
shuiyuanHelperLink.href = getShuiyuanHelperRoute();
const validKeys = new Set(ALL_FEATURES.map((f) => f.id));
let shuiyuanHelperPageActive = false;
shuiyuanHelperLink.addEventListener('click', (e0) => {
e0.preventDefault();
if (shuiyuanHelperPageActive) {
return;
}
const form = document.getElementsByClassName('form-vertical')[0];
if (!form || form.getElementsByClassName('spinner').length > 0) {
return;
}
const controlGroup = document.createElement('fieldset');
controlGroup.classList.add('control-group', 'shuiyuan-helper-settings');
const settingsLegend = document.createElement('legend');
settingsLegend.classList.add('control-label');
settingsLegend.style.marginBottom = '10px';
settingsLegend.appendChild(document.createTextNode('水源助手设置'));
controlGroup.appendChild(settingsLegend);
for (const feature of ALL_FEATURES) {
if (feature.hidden || feature.hideFromSettings) {
continue;
}
const {id: featureId, description: featureDesc, instructions: featureInstr, options, optionValues} = feature;
const optionControls = [];
controlGroup.appendChild(createCheckboxDiv(featureDesc, featureInstr, feature.enabled, null, (e) => {
const config = loadConfig();
const {checked} = e.target;
if (options) {
config[featureId] = fixOptionObject(config[featureId]);
config[featureId].enabled = checked;
} else {
config[featureId] = checked;
}
cleanUpKeys(config, validKeys);
saveConfig(config);
for (const control of optionControls) {
control.disabled = !checked;
}
}));
if (options) {
const optionValidKeys = new Set(['enabled', ...options.map((o) => o.id)]);
for (const option of options) {
const {id: optionId, description: optionDesc, instructions: optionInstr, type} = option;
switch (type) {
case 'boolean':
controlGroup.appendChild(createCheckboxDiv(optionDesc, optionInstr, optionValues[optionId], optionControls, (e) => {
const config = loadConfig();
config[featureId] = fixOptionObject(config[featureId]);
config[featureId][optionId] = e.target.checked;
cleanUpKeys(config[featureId], optionValidKeys);
cleanUpKeys(config, validKeys);
saveConfig(config);
}));
break;
case 'string':
controlGroup.appendChild(createTextInputDiv(optionDesc, optionInstr, optionValues[optionId], optionControls, (e) => {
const config = loadConfig();
config[featureId] = fixOptionObject(config[featureId]);
config[featureId][optionId] = e.target.value;
cleanUpKeys(config[featureId], optionValidKeys);
cleanUpKeys(config, validKeys);
saveConfig(config);
}));
break;
default:
throw Error(`Unknown option type: ${type}`);
}
}
if (!feature.enabled) {
for (const control of optionControls) {
control.disabled = true;
}
}
}
}
const reloadLink = document.createElement('a');
reloadLink.innerText = '重新加载页面';
reloadLink.addEventListener('click', () => window.location.reload());
const reloadLabel = document.createElement('label');
reloadLabel.style.marginTop = '10px';
reloadLabel.style.marginBottom = '10px';
reloadLabel.appendChild(reloadLink);
reloadLabel.appendChild(document.createTextNode('以应用更改'));
controlGroup.appendChild(reloadLabel);
const importExportLegend = document.createElement('legend');
importExportLegend.classList.add('control-label');
importExportLegend.style.marginBottom = '10px';
importExportLegend.appendChild(document.createTextNode('导入/导出'));
controlGroup.appendChild(importExportLegend);
const notesLabel = document.createElement('label');
notesLabel.style.marginBottom = '10px';
notesLabel.textContent = '水源助手设置仅保存在本地设备,不会通过您的帐户进行同步。';
controlGroup.appendChild(notesLabel);
const exportDiv = document.createElement('div');
exportDiv.style.marginBottom = '10px';
const exportToFileButton = createButton('导出到文件', false, () => createFileDownload(
JSON.stringify(loadConfig()),
`shuiyuan-helper-config-${new Date().toISOString().replaceAll(/[:.]/gu, '-')}.json`,
'application/json',
));
exportDiv.appendChild(exportToFileButton);
const exportToClipboardStatus = document.createElement('span');
const exportToClipboardButton = createButton('导出到剪贴板', false, async () => {
exportToClipboardButton.disabled = true;
let copySuccessful = false;
try {
await navigator.clipboard.writeText(JSON.stringify(loadConfig()));
copySuccessful = true;
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
exportToClipboardStatus.textContent = copySuccessful ? '已复制到剪贴板' : '无法复制到剪贴板';
exportToClipboardStatus.style.color = copySuccessful ? 'var(--success)' : 'var(--danger)';
await sleep(MESSAGE_DISAPPEAR_TIMEOUT);
exportToClipboardStatus.textContent = '';
exportToClipboardStatus.style.color = '';
exportToClipboardButton.disabled = false;
});
exportDiv.appendChild(exportToClipboardButton);
exportDiv.appendChild(exportToClipboardStatus);
controlGroup.appendChild(exportDiv);
const importDiv = document.createElement('div');
importDiv.style.marginBottom = '10px';
const importFromTextInput = document.createElement('input');
importFromTextInput.classList.add('input-xxlarge', 'ember-text-field', 'ember-view');
importFromTextInput.type = 'text';
importFromTextInput.placeholder = IS_MOBILE_VIEW || IS_MOBILE_DEVICE ? '在此粘贴要导入的设置文本' : '在此粘贴要导入的设置文本,或拖放设置文件到此处以导入';
const importFromTextInputDiv = document.createElement('div');
importFromTextInputDiv.style.marginBottom = '10px';
importFromTextInputDiv.appendChild(importFromTextInput);
importDiv.appendChild(importFromTextInputDiv);
const importConfigText = (content) => {
let config = null;
try {
if (!content) {
throw new Error();
}
config = JSON.parse(content);
if (!isObject(config)) {
throw new Error();
}
} catch {
alert('错误:导入的内容不是有效的 JSON 对象。');
return;
}
cleanUpKeys(config, validKeys);
if (Object.keys(config).length === 0 && !confirm('导入的设置为空设置,这将会重置水源助手设置为默认值,是否继续?')) {
return;
}
saveConfig(config);
importFromTextInput.value = JSON.stringify(config);
window.location.reload();
};
const importConfigFileHandler = (file, fileInput) => {
if (!file) {
return;
}
const reader = new FileReader();
reader.addEventListener('loadend', () => importConfigText(reader.result));
reader.readAsText(file);
if (fileInput) {
fileInput.value = '';
}
};
let dragCounter = 0;
importDiv.addEventListener('dragover', (e) => {
e.stopPropagation();
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
importDiv.addEventListener('dragenter', () => {
dragCounter += 1;
importDiv.style.border = 'dashed var(--success)';
});
importDiv.addEventListener('dragleave', () => {
dragCounter = Math.max(dragCounter - 1, 0);
if (dragCounter === 0) {
importDiv.style.border = '';
}
});
importDiv.addEventListener('drop', (e) => {
e.stopPropagation();
e.preventDefault();
dragCounter = 0;
importDiv.style.border = '';
importConfigFileHandler(e.dataTransfer.files[0]);
});
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json';
fileInput.addEventListener('change', () => importConfigFileHandler(fileInput.files[0], fileInput));
const importFromFileButton = createButton('从文件导入', false, () => fileInput.click());
importDiv.appendChild(importFromFileButton);
const importFromTextButton = createButton('导入上述文本', false, () => {
const content = importFromTextInput.value.trim();
if (!content) {
return;
}
importConfigText(content);
});
importFromTextInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
importFromTextButton.click();
}
});
importDiv.appendChild(importFromTextButton);
const resetButton = createButton('重置设置', true, () => {
if (!confirm('确实要重置水源助手设置为默认值吗?')) {
return;
}
saveConfig({});
window.location.reload();
});
importDiv.appendChild(resetButton);
controlGroup.appendChild(importDiv);
if (IS_MOBILE_VIEW) {
element.querySelector('span.selection').firstChild.textContent = '水源助手';
} else {
shuiyuanHelperLink.classList.add('active');
for (const li of existingLis) {
li.firstElementChild.classList.remove('active');
}
}
for (const el of form.children) {
el.style.display = 'none';
}
form.appendChild(controlGroup);
shuiyuanHelperPageActive = true;
if (window.location.hash !== SETTINGS_VIEW_FRAGMENT) {
window.history.pushState({}, '', `${window.location.pathname}${SETTINGS_VIEW_FRAGMENT}`);
}
});
const popStateListener = () => {
if (!document.body.contains(shuiyuanHelperLink)) {
window.removeEventListener('popstate', popStateListener);
}
if (window.location.hash === SETTINGS_VIEW_FRAGMENT) {
shuiyuanHelperLink.click();
} else {
for (const li of existingLis) {
if (isLiMatchingCurrentPath(li)) {
li.firstElementChild.click();
break;
}
}
}
};
window.addEventListener('popstate', popStateListener);
const shuiyuanHelperLi = document.createElement('li');
shuiyuanHelperLi.appendChild(shuiyuanHelperLink);
liContainer.appendChild(shuiyuanHelperLi);
const resetPrefPage = (e) => {
if (!shuiyuanHelperPageActive) {
return;
}
shuiyuanHelperPageActive = false;
const clickedNode = e.target;
const clickedLi = clickedNode.closest ? clickedNode.closest('li') : clickedNode.parentElement.closest('li');
if (IS_MOBILE_VIEW) {
element.querySelector('span.selection').firstChild.textContent = clickedLi.innerText.trim();
} else {
shuiyuanHelperLink.classList.remove('active');
}
const form = document.getElementsByClassName('form-vertical')[0];
if (!form) {
return;
}
for (const el of form.children) {
el.style.display = '';
}
form.getElementsByClassName('shuiyuan-helper-settings')[0]?.remove();
if (window.location.hash === SETTINGS_VIEW_FRAGMENT && isLiMatchingCurrentPath(clickedLi)) {
window.history.pushState({}, '', window.location.pathname);
}
};
for (const li of existingLis) {
li.addEventListener('click', resetPrefPage);
}
element.shuiyuanHelperHandled = true;
if (window.location.hash === SETTINGS_VIEW_FRAGMENT) {
const form = document.getElementsByClassName('form-vertical')[0];
let formObserver = null;
const jumpIfFormLoaded = () => {
if (form.getElementsByClassName('spinner').length === 0) {
formObserver.disconnect();
shuiyuanHelperLink.click();
}
};
formObserver = new MutationObserver(jumpIfFormLoaded);
formObserver.observe(form, {
subtree: true,
childList: true,
});
jumpIfFormLoaded();
}
},
},
{
id: 'fix-default-preferences-buttons',
description: '修复默认的偏好设置按钮的行为',
enabledByDefault: true,
hideFromSettings: true,
matchClass: ['quick-access-profile', 'user-primary-navigation'],
onMatch: (element) => {
if (element.shuiyuanHelperHandled) {
return;
}
element.shuiyuanHelperHandled = true;
const addNewListener = () => {
const preferencesButton = element.getElementsByClassName('preferences')[0];
preferencesButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const navAccountLi = document.querySelector('.preferences-nav .nav-account');
if (navAccountLi) {
navAccountLi.firstElementChild.click();
} else {
goToRoute(getPreferencesRoute());
}
});
};
let observer = null;
const addListenerIfContentLoaded = () => {
if (element.getElementsByClassName('spinner').length === 0) {
observer.disconnect();
addNewListener();
}
};
observer = new MutationObserver(addListenerIfContentLoaded);
observer.observe(element, {
subtree: true,
childList: true,
});
addListenerIfContentLoaded();
},
},
{
id: 'settings-quick-entrance',
description: '为设置面板添加快捷入口',
enabledByDefault: true,
hideFromSettings: true,
matchClass: 'hamburger-panel',
onMatch: (element) => {
const addLiToMenu = (ul, text, url, clickHandler) => {
const newLi = document.createElement('li');
const newA = document.createElement('a');
const newSpan = document.createElement('span');
newSpan.classList.add('d-label');
newSpan.innerText = text;
newA.classList.add('widget-link');
newA.title = text;
newA.href = url;
newA.addEventListener('click', clickHandler);
newA.appendChild(newSpan);
newLi.appendChild(newA);
ul.appendChild(newLi);
};
const lastUl = [...element.getElementsByClassName('menu-links')].pop();
if (lastUl.shuiyuanHelperHandled) {
return;
}
lastUl.shuiyuanHelperHandled = true;
const preferencesPath = getPreferencesRoute();
addLiToMenu(lastUl, '偏好设置', preferencesPath, (e) => {
e.preventDefault();
const navAccountLi = document.querySelector('.preferences-nav .nav-account');
if (navAccountLi) {
navAccountLi.firstElementChild.click();
} else {
goToRoute(preferencesPath);
}
});
const shuiyuanHelperPath = getShuiyuanHelperRoute();
addLiToMenu(lastUl, '水源助手设置', shuiyuanHelperPath, (e) => {
e.preventDefault();
const shuiyuanHelperLink = document.getElementById('nav-shuiyuan-helper');
if (shuiyuanHelperLink) {
shuiyuanHelperLink.click();
} else {
goToRoute(shuiyuanHelperPath);
}
});
},
},
];
// Below is generic framework code to process all features.
const observeTags = [];
const observeClasses = [];
const initExecutePromises = [];
const config = loadConfig();
// Version 1.2.0:
// Migrate retort-all-emojis.quick-access-emoji-list config to emoji-picker-enhancements.quick-access-emoji-list
// This is a one-time migration and can be removed at next version.
const oldQuickAccessEmojiList = config['retort-all-emojis']?.['quick-access-emoji-list'];
if (oldQuickAccessEmojiList) {
config['emoji-picker-enhancements'] = fixOptionObject(config['emoji-picker-enhancements']);
config['emoji-picker-enhancements']['quick-access-emoji-list'] = oldQuickAccessEmojiList;
delete config['retort-all-emojis'];
saveConfig(config);
}
// End version 1.2.0 migration.
for (const feature of ALL_FEATURES) {
if (feature.hidden) {
continue;
}
if (feature.options) {
const optionValues = fixOptionObject(config[feature.id]);
feature.enabled = Boolean(optionValues.enabled ?? feature.enabledByDefault);
for (const option of feature.options) {
const {id, type, defaultValue} = option;
switch (type) {
case 'boolean':
optionValues[id] = Boolean(optionValues[id] ?? defaultValue);
break;
case 'string':
optionValues[id] = parseStringOption(optionValues[id], defaultValue);
break;
default:
throw Error(`Unknown option type: ${type}`);
}
}
feature.optionValues = optionValues;
} else {
feature.enabled = Boolean(config[feature.id] ?? feature.enabledByDefault);
}
if (!feature.enabled) {
continue;
}
try {
initExecutePromises.push(Promise.resolve(feature.onInitialize?.(feature.optionValues)));
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
if (!feature.matchTag) {
feature.matchTag = [];
} else if (!Array.isArray(feature.matchTag)) {
feature.matchTag = [feature.matchTag];
}
if (!feature.matchClass) {
feature.matchClass = [];
} else if (!Array.isArray(feature.matchClass)) {
feature.matchClass = [feature.matchClass];
}
feature.selector = [...feature.matchTag, ...feature.matchClass.map((cl) => `.${cl}`)].join(', ');
observeTags.push(...feature.matchTag);
observeClasses.push(...feature.matchClass);
}
await promiseAllSettledLogErrors(initExecutePromises);
const handleNewElement = async (element, args) => {
if (!element) {
return;
}
// If this is an existing element getting new classes that we want to match, we only call `onMatch` for those new classes.
const testElement = args?.addedClasses ? dummyElementOfClasses(args.addedClasses) : element;
const promises = [];
for (const feature of ALL_FEATURES) {
if (!feature.enabled || !feature.selector) {
continue;
}
if (testElement.matches(feature.selector)) {
try {
promises.push(Promise.resolve(feature.onMatch(element, feature.optionValues)));
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
}
}
await promiseAllSettledLogErrors(promises);
};
const observeTagsSet = new Set(observeTags);
const observeClassesSet = new Set(observeClasses);
const tagMatch = (node) => observeTagsSet.has(node.nodeName.toLowerCase());
const classMatch = (node) => setsOverlap(observeClassesSet, new Set(node.classList));
const globalObserver = new MutationObserver(async (mutationsList) => {
const promises = [];
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (tagMatch(node) || classMatch(node)) {
promises.push(handleNewElement(node));
}
if (node.getElementsByTagName) {
promises.push(...getElementsByTagNames(node, observeTags).map(handleNewElement));
}
if (node.getElementsByClassName) {
promises.push(...getElementsByClassNames(node, observeClasses).map(handleNewElement));
}
}
} else if (mutation.type === 'attributes') {
if (mutation.attributeName === 'class') {
const addedClasses = setDifference(new Set(mutation.target.classList), new Set(dummyElementOfClasses(mutation.oldValue ?? '').classList));
if (setsOverlap(observeClassesSet, addedClasses)) {
promises.push(handleNewElement(mutation.target, {addedClasses}));
}
}
}
}
await promiseAllSettledLogErrors(promises);
});
globalObserver.observe(document.documentElement, {
subtree: true,
childList: true,
attributeFilter: ['class'],
attributeOldValue: true,
});
await promiseAllSettledLogErrors([
...getElementsByTagNames(document, observeTags).map(handleNewElement),
...getElementsByClassNames(document, observeClasses).map(handleNewElement),
]);
})();