clientside trie
This commit is contained in:
@@ -5,18 +5,10 @@ class Domain::Fa::ApiController < ApplicationController
|
||||
def search_users
|
||||
name = params[:name]
|
||||
limit = (params[:limit] || 10).to_i.clamp(0, 15)
|
||||
users = relevant = Domain::Fa::User.where([
|
||||
"(name ilike :name) OR (url_name ilike :name)",
|
||||
{ name: "#{ReduxApplicationRecord.sanitize_sql_like(name)}%" },
|
||||
]).order(name: :asc).
|
||||
limit(limit).
|
||||
pluck(:id, :name, :url_name).
|
||||
users = relevant = users_for_name(name).
|
||||
map do |id, name, url_name|
|
||||
{ id: id, name: name, url_name: url_name }
|
||||
end
|
||||
if params[:force_sleep]
|
||||
sleep 2
|
||||
end
|
||||
if !Rails.env.production? && name == "error"
|
||||
render status: 500, json: { error: "an error!" }
|
||||
else
|
||||
@@ -24,6 +16,25 @@ class Domain::Fa::ApiController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def load_users
|
||||
infile = Rails.root.join("app/javascript/server/buildUsersTrie.ts").to_s
|
||||
outfile = infile[0..-(File.extname(infile).length + 1)] + ".js"
|
||||
if !FileUtils.uptodate?(outfile, [infile])
|
||||
logger.info("rebuild #{infile} -> #{outfile}...")
|
||||
cmd = "yarn exec tsc -- --target es2015 --downlevelIteration --moduleResolution nodenext #{infile}"
|
||||
out = `#{cmd}`
|
||||
raise out unless $?.success?
|
||||
logger.info("#{cmd}: #{out}")
|
||||
end
|
||||
digest = Digest::SHA256.hexdigest(File.read(outfile))
|
||||
load_users_blob = Rails.cache.fetch("load_users_#{digest}", expires_in: 24.hours) do
|
||||
script = ExecJS.compile(File.read(outfile))
|
||||
all_users = Domain::Fa::User.all.pluck(:id, :name)
|
||||
script.call("buildUsersTrie", all_users)
|
||||
end
|
||||
render plain: load_users_blob, content_type: "application/json"
|
||||
end
|
||||
|
||||
def object_statuses
|
||||
fa_ids = (params[:fa_ids] || []).map(&:to_i)
|
||||
url_names = (params[:url_names] || [])
|
||||
@@ -348,4 +359,168 @@ class Domain::Fa::ApiController < ApplicationController
|
||||
"never"
|
||||
end
|
||||
end
|
||||
|
||||
def users_for_name(name)
|
||||
return USERS if Rails.env.development?
|
||||
|
||||
Domain::Fa::User.where([
|
||||
"(name ilike :name) OR (url_name ilike :name)",
|
||||
{ name: "#{ReduxApplicationRecord.sanitize_sql_like(name)}%" },
|
||||
]).order(name: :asc).
|
||||
limit(limit).
|
||||
pluck(:id, :name, :url_name)
|
||||
end
|
||||
|
||||
USERS = [
|
||||
[102661, "Twee", "twee"],
|
||||
[102836, "Kobrin", "kobrin"],
|
||||
[106292, "manadspaniel", "manadspaniel"],
|
||||
[121171, "Sakura_the_wolf", "sakurathewolf"],
|
||||
[123481, "Kai~skunk", "kai~skunk"],
|
||||
[131219, "Luna.Moonflower", "luna.moonflower"],
|
||||
[132018, "HeyImDaddy", "heyimdaddy"],
|
||||
[132032, "frankenbunny", "frankenbunny"],
|
||||
[154415, "Gh0stlyFurry", "gh0stlyfurry"],
|
||||
[158837, "4r_da_Sqauw", "4rdasqauw"],
|
||||
[173905, "ZombieUnicorn", "zombieunicorn"],
|
||||
[181599, "space_fruit", "spacefruit"],
|
||||
[182869, "Ragd360", "ragd360"],
|
||||
[185649, "IBluefoxI", "ibluefoxi"],
|
||||
[188437, "Yuricupcakes", "yuricupcakes"],
|
||||
[190487, "lingaring", "lingaring"],
|
||||
[197704, "Rayneier", "rayneier"],
|
||||
[201974, "Clow", "clow"],
|
||||
[208562, "rdraft", "rdraft"],
|
||||
[215738, "Kaori_Keia", "kaorikeia"],
|
||||
[22036, "tiddys", "tiddys"],
|
||||
[223999, "SwampPossum", "swamppossum"],
|
||||
[22841, "k-inukai", "k-inukai"],
|
||||
[232171, "IPfix_da_Firefox", "ipfixdafirefox"],
|
||||
[235933, "VisibleSuicide", "visiblesuicide"],
|
||||
[258806, "Wizmas", "wizmas"],
|
||||
[273285, "furrything", "furrything"],
|
||||
[273634, "AngieTheOtter", "angietheotter"],
|
||||
[282615, "catsrulekg", "catsrulekg"],
|
||||
[285410, "Icyskills", "icyskills"],
|
||||
[291374, "autumnfoxfur", "autumnfoxfur"],
|
||||
[293860, "katamarulte", "katamarulte"],
|
||||
[306985, "RadioactiveF0X", "radioactivef0x"],
|
||||
[319368, "Soren123", "soren123"],
|
||||
[349997, "kuroneko---sama", "kuroneko---sama"],
|
||||
[350090, "SkylesFawkx", "skylesfawkx"],
|
||||
[355982, "Shirilee", "shirilee"],
|
||||
[358587, "Shosta", "shosta"],
|
||||
[363811, "WhiteKIBA", "whitekiba"],
|
||||
[374231, "starswillscream", "starswillscream"],
|
||||
[379724, "dub-c", "dub-c"],
|
||||
[386887, "Kirei-Uchiha", "kirei-uchiha"],
|
||||
[407214, "MagicOFManga", "magicofmanga"],
|
||||
[412404, "PoppyPath", "poppypath"],
|
||||
[412923, "Sebastian_teh_Dober", "sebastiantehdober"],
|
||||
[416072, "declinedkitty", "declinedkitty"],
|
||||
[418580, "X-MXNE666", "x-mxne666"],
|
||||
[418660, "Peelpeelpeel1", "peelpeelpeel1"],
|
||||
[419237, "Fennixthepawlicker", "fennixthepawlicker"],
|
||||
[431318, "GregorianBurns", "gregorianburns"],
|
||||
[436280, "fionnaidh", "fionnaidh"],
|
||||
[43684, "MLBat-Darkheart", "mlbat-darkheart"],
|
||||
[437721, "White_Dragon_sisu", "whitedragonsisu"],
|
||||
[441002, "someSkylah", "someskylah"],
|
||||
[441268, "Eviscerator", "eviscerator"],
|
||||
[442275, "HumanistFurs", "humanistfurs"],
|
||||
[445549, "Ayrich", "ayrich"],
|
||||
[448023, "NastyCalamari", "nastycalamari"],
|
||||
[455220, "analfurs", "analfurs"],
|
||||
[459851, "TotallySafe", "totallysafe"],
|
||||
[464719, "Shadowfur13", "shadowfur13"],
|
||||
[46619, "CandyShop", "candyshop"],
|
||||
[466695, "ElFurroKawaii", "elfurrokawaii"],
|
||||
[467565, "Brygaht", "brygaht"],
|
||||
[472867, "Mitryll", "mitryll"],
|
||||
[479071, "Shamanroach", "shamanroach"],
|
||||
[497090, "!BlakeTheGamer", "blakethegamer"],
|
||||
[507923, "Guro-Jans", "guro-jans"],
|
||||
[520548, "Elseano", "elseano"],
|
||||
[530399, "PUMPKINLIGHTTHEKITSUNE", "pumpkinlightthekitsune"],
|
||||
[531212, "Ulti_199", "ulti199"],
|
||||
[532461, "DozyDoe", "dozydoe"],
|
||||
[537220, "fentible", "fentible"],
|
||||
[539148, "StealthyIntheDark", "stealthyinthedark"],
|
||||
[545990, "odinokiy", "odinokiy"],
|
||||
[552205, "Namorria", "namorria"],
|
||||
[557322, "Katana2", "katana2"],
|
||||
[56360, "t0rrid", "t0rrid"],
|
||||
[570571, "Zoralth8745", "zoralth8745"],
|
||||
[570771, "Swagging", "swagging"],
|
||||
[574502, "AMeanCow", "ameancow"],
|
||||
[576501, "CaptianYeti", "captianyeti"],
|
||||
[578205, "cyphurel", "cyphurel"],
|
||||
[580442, "rraw", "rraw"],
|
||||
[584446, "HelveluxDragon", "helveluxdragon"],
|
||||
[592829, "clownshoes", "clownshoes"],
|
||||
[594599, "AxisthewoIf", "axisthewoif"],
|
||||
[597726, "GypsyCavarow", "gypsycavarow"],
|
||||
[598375, "Cloud_Heart_Saber", "cloudheartsaber"],
|
||||
[608812, "MidnightMapleMoon", "midnightmaplemoon"],
|
||||
[608859, "pasteldad", "pasteldad"],
|
||||
[609253, "mezzoforte2", "mezzoforte2"],
|
||||
[616675, "Lil_Naz", "lilnaz"],
|
||||
[617918, "tristian", "tristian"],
|
||||
[629272, "esenya_valenok", "esenyavalenok"],
|
||||
[637893, "Ink-EyesBae", "ink-eyesbae"],
|
||||
[638713, "NavigatorTP", "navigatortp"],
|
||||
[642236, "-EdenArcane-", "-edenarcane-"],
|
||||
[660560, "Nevera66", "nevera66"],
|
||||
[678313, "mog17", "mog17"],
|
||||
[694556, "veroaid", "veroaid"],
|
||||
[695130, "Furry_Writers", "furrywriters"],
|
||||
[697418, "Lazywerewolf", "lazywerewolf"],
|
||||
[711410, "Seas1de", "seas1de"],
|
||||
[713084, "fuzzmuffin23", "fuzzmuffin23"],
|
||||
[713759, "alp_Rik", "alprik"],
|
||||
[716200, "RequiemDreamer", "requiemdreamer"],
|
||||
[718333, "KhorneSoup", "khornesoup"],
|
||||
[73267, "splendidguy44", "splendidguy44"],
|
||||
[738005, "aurokhan", "aurokhan"],
|
||||
[742214, "velin.art", "velin.art"],
|
||||
[742375, "vampiretigress", "vampiretigress"],
|
||||
[752692, "FlairMalin", "flairmalin"],
|
||||
[756325, "Thorn_Mochag", "thornmochag"],
|
||||
[75693, "numbsoul", "numbsoul"],
|
||||
[758113, "leetf0x", "leetf0x"],
|
||||
[758475, "Naspyta", "naspyta"],
|
||||
[763834, "dankkushlove420", "dankkushlove420"],
|
||||
[773150, "Fokatuh", "fokatuh"],
|
||||
[777382, "Yorrick67", "yorrick67"],
|
||||
[783391, "lovebigtits94", "lovebigtits94"],
|
||||
[790219, "ValiantBadger", "valiantbadger"],
|
||||
[793308, "promqueen192", "promqueen192"],
|
||||
[794155, "Sola_the_Solgaleo", "solathesolgaleo"],
|
||||
[802477, "kempo2k", "kempo2k"],
|
||||
[804770, "vinv", "vinv"],
|
||||
[808277, "AzartAlien", "azartalien"],
|
||||
[819527, "curiosity6678", "curiosity6678"],
|
||||
[820429, "GrayGrim", "graygrim"],
|
||||
[822085, "rRICHr", "rrichr"],
|
||||
[835929, "Kovuska", "kovuska"],
|
||||
[836315, "knotthatbun", "knotthatbun"],
|
||||
[836547, "1223223", "1223223"],
|
||||
[841669, "DisheveledSloth", "disheveledsloth"],
|
||||
[84257, "Naktian-Furs", "naktian-furs"],
|
||||
[844032, "Fluffy_kozlik", "fluffykozlik"],
|
||||
[854728, "pierre_dunn", "pierredunn"],
|
||||
[856538, "onlyheretosponser", "onlyheretosponser"],
|
||||
[861416, "tailsthefoxrules123", "tailsthefoxrules123"],
|
||||
[862656, "EVXFY", "evxfy"],
|
||||
[865110, "LowkeyArt", "lowkeyart"],
|
||||
[873049, "Tedy", "tedy"],
|
||||
[875191, "JamesTec666", "jamestec666"],
|
||||
[875416, "Obscurum", "obscurum"],
|
||||
[879928, "Solious6233", "solious6233"],
|
||||
[882664, "princepixelbunny", "princepixelbunny"],
|
||||
[887058, "Pyrote", "pyrote"],
|
||||
[90246, "sonupi", "sonupi"],
|
||||
[92111, "zevialexander", "zevialexander"],
|
||||
[92831, "foxmacro", "foxmacro"],
|
||||
]
|
||||
end
|
||||
|
||||
@@ -3,10 +3,11 @@ import * as React from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Icon from "./Icon";
|
||||
import ListItem from "./ListItem";
|
||||
import Trie, { TrieNode } from "../lib/Trie";
|
||||
|
||||
// staging
|
||||
// const HOST = "http://scraper.local:3001";
|
||||
const HOST = "";
|
||||
const HOST = "http://scraper.local:3001";
|
||||
// const HOST = "";
|
||||
const COMMON_LIST_ELEM_CLASSES = `
|
||||
w-full p-2 pl-8
|
||||
text-xl font-light
|
||||
@@ -32,6 +33,9 @@ interface PropTypes {
|
||||
interface ServerResponse {
|
||||
users: { name: string }[];
|
||||
}
|
||||
type TrieValue = [number, string];
|
||||
type TrieType = Trie<TrieValue>;
|
||||
type TrieNodeType = TrieNode<TrieValue>;
|
||||
export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
isServerRendered = !!isServerRendered;
|
||||
const [userName, setUserName] = useState("");
|
||||
@@ -42,12 +46,36 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
null
|
||||
);
|
||||
const [typingSettled, setTypingSettled] = useState(true);
|
||||
const [trie, setTrie] = useState<TrieType | null>(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const [isFocused, setIsFocused] = useState(
|
||||
isServerRendered ? false : document.activeElement == inputRef.current
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
async function loadUserTrie() {
|
||||
try {
|
||||
let req = await fetch(`${HOST}/api/fa/load_users`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (req.status != 200) {
|
||||
setErrorMessage(`error loading users: ${await req.body}`);
|
||||
} else {
|
||||
let trieSerialized = await req.json();
|
||||
const trie: TrieType = new Trie(trieSerialized);
|
||||
setErrorMessage("loaded trie");
|
||||
setTrie(trie);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("error loading user trie: ", err);
|
||||
}
|
||||
}
|
||||
loadUserTrie();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const clearResults = useCallback(() => {
|
||||
setUserList([]);
|
||||
setErrorMessage(null);
|
||||
@@ -71,68 +99,108 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
);
|
||||
|
||||
const searchForUser = useCallback(
|
||||
(userName) => {
|
||||
setTypingSettled(false);
|
||||
cancelPendingRequest();
|
||||
if (isEmpty(userName)) {
|
||||
(userName: string) => {
|
||||
// setTypingSettled(false);
|
||||
// cancelPendingRequest();
|
||||
if (trie == null) {
|
||||
return;
|
||||
} else if (isEmpty(userName)) {
|
||||
clearResults();
|
||||
setUserName(userName);
|
||||
setUserName("");
|
||||
} else {
|
||||
searchForUserDebounced(userName);
|
||||
const { node } = trie.nodeForPrefix(userName);
|
||||
if (node) {
|
||||
const users = nodeToList(node, 5).map((node) => ({
|
||||
name: `${node.value[1]} (${node.value[0]})`,
|
||||
}));
|
||||
console.log("Set users", users);
|
||||
setUserList(users);
|
||||
}
|
||||
}
|
||||
},
|
||||
[cancelPendingRequest]
|
||||
[cancelPendingRequest, trie]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty(userName)) {
|
||||
return;
|
||||
function nodeToList(node: TrieNodeType, limit: number): TrieNodeType[] {
|
||||
const list = [];
|
||||
nodeToListImpl(list, node, limit);
|
||||
return list;
|
||||
}
|
||||
|
||||
function nodeToListImpl(
|
||||
listOut: TrieNodeType[],
|
||||
node: TrieNodeType,
|
||||
limit: number
|
||||
) {
|
||||
if (listOut.length >= limit) return;
|
||||
|
||||
if (node.terminal) {
|
||||
listOut.push(node);
|
||||
if (listOut.length >= limit) return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setPendingRequest(controller);
|
||||
async function doFetch() {
|
||||
try {
|
||||
let response = await fetch(
|
||||
`${HOST}/api/fa/search_users?name=${encodeURIComponent(userName)}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
await handleServerResponse(response);
|
||||
} catch (error) {
|
||||
if (error.name == "AbortError") {
|
||||
return;
|
||||
}
|
||||
clearResults();
|
||||
setErrorMessage(error.toString());
|
||||
} finally {
|
||||
setPendingRequest(null);
|
||||
}
|
||||
for (const [childKey, child] of node.children.entries()) {
|
||||
nodeToListImpl(listOut, child, limit);
|
||||
if (listOut.length >= limit) return;
|
||||
}
|
||||
doFetch();
|
||||
return () => {
|
||||
cancelPendingRequest();
|
||||
};
|
||||
}, [userName]);
|
||||
}
|
||||
|
||||
const handleServerResponse = useCallback(async (response) => {
|
||||
clearResults();
|
||||
const contentType: string = response.headers.get("Content-Type");
|
||||
if (response.status == 200) {
|
||||
const { users }: ServerResponse = await response.json();
|
||||
setUserList(users);
|
||||
} else if (
|
||||
!isEmpty(contentType) &&
|
||||
contentType.startsWith("application/json")
|
||||
) {
|
||||
const { error } = await response.json();
|
||||
setErrorMessage(`${response.status}: ${error}`);
|
||||
} else {
|
||||
const text = await response.text();
|
||||
setErrorMessage(`${response.status}: ${text}`);
|
||||
}
|
||||
}, []);
|
||||
// useEffect(() => {
|
||||
// if (isEmpty(userName)) {
|
||||
// return;
|
||||
// }
|
||||
// if (trie == null) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const node = trie.nodeForPrefix(userName);
|
||||
// setUserList([{ name: `node ${node}` }]);
|
||||
|
||||
// // const controller = new AbortController();
|
||||
// // setPendingRequest(controller);
|
||||
// // async function doFetch() {
|
||||
// // try {
|
||||
// // let response = await fetch(
|
||||
// // `${HOST}/api/fa/search_users?name=${encodeURIComponent(userName)}`,
|
||||
// // {
|
||||
// // signal: controller.signal,
|
||||
// // }
|
||||
// // );
|
||||
// // await handleServerResponse(response);
|
||||
// // } catch (error) {
|
||||
// // if (error.name == "AbortError") {
|
||||
// // return;
|
||||
// // }
|
||||
// // clearResults();
|
||||
// // setErrorMessage(error.toString());
|
||||
// // } finally {
|
||||
// // setPendingRequest(null);
|
||||
// // }
|
||||
// // }
|
||||
// // doFetch();
|
||||
|
||||
// return () => {
|
||||
// cancelPendingRequest();
|
||||
// };
|
||||
// }, [userName, trie]);
|
||||
|
||||
// const handleServerResponse = useCallback(async (response) => {
|
||||
// clearResults();
|
||||
// const contentType: string = response.headers.get("Content-Type");
|
||||
// if (response.status == 200) {
|
||||
// const { users }: ServerResponse = await response.json();
|
||||
// setUserList(users);
|
||||
// } else if (
|
||||
// !isEmpty(contentType) &&
|
||||
// contentType.startsWith("application/json")
|
||||
// ) {
|
||||
// const { error } = await response.json();
|
||||
// setErrorMessage(`${response.status}: ${error}`);
|
||||
// } else {
|
||||
// const text = await response.text();
|
||||
// setErrorMessage(`${response.status}: ${text}`);
|
||||
// }
|
||||
// }, []);
|
||||
|
||||
function onSearchInputKeyDown(event) {
|
||||
const { code, shiftKey } = event;
|
||||
|
||||
93
app/javascript/bundles/Main/lib/Trie.ts
Normal file
93
app/javascript/bundles/Main/lib/Trie.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { exact } from "prop-types";
|
||||
|
||||
interface SerializedTrie<T> {
|
||||
// terminal node?
|
||||
t: 1 | 0;
|
||||
// value of the node
|
||||
v: T;
|
||||
// optional children
|
||||
c?: { [s: string]: SerializedTrie<T> };
|
||||
}
|
||||
|
||||
export class TrieNode<T> {
|
||||
public terminal: boolean;
|
||||
public value: T;
|
||||
public children: Map<string, TrieNode<T>>;
|
||||
public serialized: SerializedTrie<T>;
|
||||
|
||||
constructor(ser: SerializedTrie<T>) {
|
||||
this.terminal = ser.t == 1;
|
||||
this.value = ser.v;
|
||||
this.children = new Map();
|
||||
this.serialized = ser;
|
||||
|
||||
if (ser.c != null) {
|
||||
for (const [key, value] of Object.entries(ser.c)) {
|
||||
this.children.set(key, new TrieNode(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class Trie<T> {
|
||||
public root: TrieNode<T>;
|
||||
constructor(ser: SerializedTrie<T>) {
|
||||
this.root = new TrieNode(ser);
|
||||
}
|
||||
|
||||
public nodeForPrefix(key: string): {
|
||||
chain: string[];
|
||||
node: TrieNode<T> | null;
|
||||
} {
|
||||
let chain = [];
|
||||
let node = this.root;
|
||||
let remaining = key;
|
||||
while (node && remaining.length > 0) {
|
||||
let exactChild = null;
|
||||
console.log("remaining: ", remaining);
|
||||
|
||||
for (const [childKey, child] of node.children.entries()) {
|
||||
if (remaining.startsWith(childKey)) {
|
||||
console.log("exact match for: ", childKey);
|
||||
exactChild = child;
|
||||
chain.push(childKey);
|
||||
remaining = remaining.slice(childKey.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// if an exact match was found, continue iterating
|
||||
if (exactChild) {
|
||||
node = exactChild;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log("looking for partial match for ", remaining);
|
||||
for (const [childKey, child] of node.children.entries()) {
|
||||
const startsWith = childKey.startsWith(remaining);
|
||||
console.log(
|
||||
"test ",
|
||||
childKey,
|
||||
" against ",
|
||||
remaining,
|
||||
": ",
|
||||
startsWith,
|
||||
" ",
|
||||
child.serialized
|
||||
);
|
||||
if (startsWith) {
|
||||
console.log("partial match for: ", remaining, ": ", child.serialized);
|
||||
chain.push(childKey);
|
||||
return { chain, node: child };
|
||||
}
|
||||
}
|
||||
|
||||
console.log("did not find partial, bailing!");
|
||||
return { chain, node: null };
|
||||
}
|
||||
|
||||
// // return remaining.length === 0 && node && node.terminal ? node : null;
|
||||
console.log("returning child ", node, " for remaining ", remaining);
|
||||
return { chain, node };
|
||||
}
|
||||
}
|
||||
140
app/javascript/server/buildUsersTrie.js
Normal file
140
app/javascript/server/buildUsersTrie.js
Normal file
@@ -0,0 +1,140 @@
|
||||
function buildUsersTrie(users) {
|
||||
const rootNode = new trie();
|
||||
users.forEach(([id, name, urlName]) => {
|
||||
rootNode.insert(name.toLowerCase(), [id, name, urlName]);
|
||||
});
|
||||
return JSON.stringify(rootNode.serialize());
|
||||
}
|
||||
class trie_node {
|
||||
constructor() {
|
||||
this.terminal = false;
|
||||
this.children = new Map();
|
||||
}
|
||||
serialize() {
|
||||
const { terminal, value, children } = this;
|
||||
let mapped = {};
|
||||
let numChildren = 0;
|
||||
Object.keys(Object.fromEntries(children)).forEach((childKey) => {
|
||||
numChildren += 1;
|
||||
mapped[childKey] = children.get(childKey).serialize();
|
||||
});
|
||||
return {
|
||||
t: this.terminal ? 1 : 0,
|
||||
v: value,
|
||||
c: numChildren > 0 ? mapped : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
class trie {
|
||||
constructor() {
|
||||
this.root = new trie_node();
|
||||
this.elements = 0;
|
||||
}
|
||||
serialize() {
|
||||
return this.root.serialize();
|
||||
}
|
||||
get length() {
|
||||
return this.elements;
|
||||
}
|
||||
get(key) {
|
||||
const node = this.getNode(key);
|
||||
if (node) {
|
||||
return node.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
contains(key) {
|
||||
const node = this.getNode(key);
|
||||
return !!node;
|
||||
}
|
||||
insert(key, value) {
|
||||
let node = this.root;
|
||||
let remaining = key;
|
||||
while (remaining.length > 0) {
|
||||
let child = null;
|
||||
for (const childKey of node.children.keys()) {
|
||||
const prefix = this.commonPrefix(remaining, childKey);
|
||||
if (!prefix.length) {
|
||||
continue;
|
||||
}
|
||||
if (prefix.length === childKey.length) {
|
||||
// enter child node
|
||||
child = node.children.get(childKey);
|
||||
remaining = remaining.slice(childKey.length);
|
||||
break;
|
||||
}
|
||||
else {
|
||||
// split the child
|
||||
child = new trie_node();
|
||||
child.children.set(childKey.slice(prefix.length), node.children.get(childKey));
|
||||
node.children.delete(childKey);
|
||||
node.children.set(prefix, child);
|
||||
remaining = remaining.slice(prefix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!child && remaining.length) {
|
||||
child = new trie_node();
|
||||
node.children.set(remaining, child);
|
||||
remaining = "";
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
if (!node.terminal) {
|
||||
node.terminal = true;
|
||||
this.elements += 1;
|
||||
}
|
||||
node.value = value;
|
||||
}
|
||||
remove(key) {
|
||||
const node = this.getNode(key);
|
||||
if (node) {
|
||||
node.terminal = false;
|
||||
this.elements -= 1;
|
||||
}
|
||||
}
|
||||
map(prefix, func) {
|
||||
const mapped = [];
|
||||
const node = this.getNode(prefix);
|
||||
const stack = [];
|
||||
if (node) {
|
||||
stack.push([prefix, node]);
|
||||
}
|
||||
while (stack.length) {
|
||||
const [key, node] = stack.pop();
|
||||
if (node.terminal) {
|
||||
mapped.push(func(key, node.value));
|
||||
}
|
||||
for (const c of node.children.keys()) {
|
||||
stack.push([key + c, node.children.get(c)]);
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
getNode(key) {
|
||||
let node = this.root;
|
||||
let remaining = key;
|
||||
while (node && remaining.length > 0) {
|
||||
let child = null;
|
||||
for (let i = 1; i <= remaining.length; i += 1) {
|
||||
child = node.children.get(remaining.slice(0, i));
|
||||
if (child) {
|
||||
remaining = remaining.slice(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
return remaining.length === 0 && node && node.terminal ? node : null;
|
||||
}
|
||||
commonPrefix(a, b) {
|
||||
const shortest = Math.min(a.length, b.length);
|
||||
let i = 0;
|
||||
for (; i < shortest; i += 1) {
|
||||
if (a[i] !== b[i]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return a.slice(0, i);
|
||||
}
|
||||
}
|
||||
163
app/javascript/server/buildUsersTrie.ts
Normal file
163
app/javascript/server/buildUsersTrie.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
type UserRow = [number, string];
|
||||
|
||||
function buildUsersTrie(users: UserRow[]): string {
|
||||
const rootNode = new trie<[number, string]>();
|
||||
users.forEach(([id, name]) => {
|
||||
rootNode.insert(name.toLowerCase(), [id, name]);
|
||||
});
|
||||
return JSON.stringify(rootNode.serialize());
|
||||
}
|
||||
|
||||
class trie_node<T> {
|
||||
public terminal: boolean;
|
||||
public value: T;
|
||||
public children: Map<string, trie_node<T>>;
|
||||
|
||||
constructor() {
|
||||
this.terminal = false;
|
||||
this.children = new Map();
|
||||
}
|
||||
|
||||
public serialize(): Object {
|
||||
const { terminal, value, children } = this;
|
||||
let mapped = {};
|
||||
let numChildren = 0;
|
||||
Object.keys(Object.fromEntries(children)).forEach((childKey) => {
|
||||
numChildren += 1;
|
||||
mapped[childKey] = children.get(childKey).serialize();
|
||||
});
|
||||
return {
|
||||
t: this.terminal ? 1 : 0,
|
||||
v: value,
|
||||
c: numChildren > 0 ? mapped : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class trie<T> {
|
||||
public root: trie_node<T>;
|
||||
public elements: number;
|
||||
|
||||
constructor() {
|
||||
this.root = new trie_node<T>();
|
||||
this.elements = 0;
|
||||
}
|
||||
|
||||
public serialize(): Object {
|
||||
return this.root.serialize();
|
||||
}
|
||||
|
||||
public get length(): number {
|
||||
return this.elements;
|
||||
}
|
||||
|
||||
public get(key: string): T | null {
|
||||
const node = this.getNode(key);
|
||||
if (node) {
|
||||
return node.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public contains(key: string): boolean {
|
||||
const node = this.getNode(key);
|
||||
return !!node;
|
||||
}
|
||||
|
||||
public insert(key: string, value: T): void {
|
||||
let node = this.root;
|
||||
let remaining = key;
|
||||
while (remaining.length > 0) {
|
||||
let child: trie_node<T> = null;
|
||||
for (const childKey of node.children.keys()) {
|
||||
const prefix = this.commonPrefix(remaining, childKey);
|
||||
if (!prefix.length) {
|
||||
continue;
|
||||
}
|
||||
if (prefix.length === childKey.length) {
|
||||
// enter child node
|
||||
child = node.children.get(childKey);
|
||||
remaining = remaining.slice(childKey.length);
|
||||
break;
|
||||
} else {
|
||||
// split the child
|
||||
child = new trie_node<T>();
|
||||
child.children.set(
|
||||
childKey.slice(prefix.length),
|
||||
node.children.get(childKey)
|
||||
);
|
||||
node.children.delete(childKey);
|
||||
node.children.set(prefix, child);
|
||||
remaining = remaining.slice(prefix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!child && remaining.length) {
|
||||
child = new trie_node<T>();
|
||||
node.children.set(remaining, child);
|
||||
remaining = "";
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
if (!node.terminal) {
|
||||
node.terminal = true;
|
||||
this.elements += 1;
|
||||
}
|
||||
node.value = value;
|
||||
}
|
||||
|
||||
public remove(key: string): void {
|
||||
const node = this.getNode(key);
|
||||
if (node) {
|
||||
node.terminal = false;
|
||||
this.elements -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
public map<U>(prefix: string, func: (key: string, value: T) => U): U[] {
|
||||
const mapped = [];
|
||||
const node = this.getNode(prefix);
|
||||
const stack: [string, trie_node<T>][] = [];
|
||||
if (node) {
|
||||
stack.push([prefix, node]);
|
||||
}
|
||||
while (stack.length) {
|
||||
const [key, node] = stack.pop();
|
||||
if (node.terminal) {
|
||||
mapped.push(func(key, node.value));
|
||||
}
|
||||
for (const c of node.children.keys()) {
|
||||
stack.push([key + c, node.children.get(c)]);
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
private getNode(key: string): trie_node<T> | null {
|
||||
let node = this.root;
|
||||
let remaining = key;
|
||||
while (node && remaining.length > 0) {
|
||||
let child = null;
|
||||
for (let i = 1; i <= remaining.length; i += 1) {
|
||||
child = node.children.get(remaining.slice(0, i));
|
||||
if (child) {
|
||||
remaining = remaining.slice(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
return remaining.length === 0 && node && node.terminal ? node : null;
|
||||
}
|
||||
|
||||
private commonPrefix(a: string, b: string): string {
|
||||
const shortest = Math.min(a.length, b.length);
|
||||
let i = 0;
|
||||
for (; i < shortest; i += 1) {
|
||||
if (a[i] !== b[i]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return a.slice(0, i);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ Rails.application.configure do
|
||||
config.hosts << "localhost"
|
||||
config.hosts << "scraper.local"
|
||||
|
||||
config.middleware.insert_after ActionDispatch::Static, Rack::Deflater
|
||||
|
||||
# Code is not reloaded between requests.
|
||||
config.cache_classes = true
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors, debug: true do
|
||||
allow do
|
||||
origins "localhost:3000"
|
||||
resource "/api/fa/search_users", headers: :any, methods: [:get, :options]
|
||||
resource "/api/fa/load_users", headers: :any, methods: [:get, :options]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ Rails.application.routes.draw do
|
||||
namespace :fa do
|
||||
get :similar_users, to: "/domain/fa/api#similar_users"
|
||||
get :search_users, to: "/domain/fa/api#search_users"
|
||||
get :load_users, to: "/domain/fa/api#load_users"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"sourceMap": true,
|
||||
"target": "es5",
|
||||
"jsx": "react",
|
||||
"noEmit": true
|
||||
"noEmit": true,
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": ["app/javascript/**/*"],
|
||||
"exclude": ["**/*.spec.ts", "node_modules", "vendor", "public"],
|
||||
|
||||
Reference in New Issue
Block a user