Compare commits

...

44 Commits
main ... 4.17.0

Author SHA1 Message Date
Alikia2x
421e4fcdb8 feature: intention detection by nlp.js, with demo of showing weather 2024-06-19 17:14:23 +08:00
Alikia2x
669ad510ed merge: branch dev into ref-rule-nlp 2024-06-17 18:52:03 +08:00
Alikia2x
5519d313ee add: document by vitepress 2024-06-17 18:51:49 +08:00
Alikia2x
e7f6f69dfe temp: NLP base class 2024-06-17 03:11:17 +08:00
Alikia2x
f85162622d fix: style of suggestion item 2024-06-14 19:55:29 +08:00
Alikia2x
24f23b3573 improve: base64 tool 2024-05-15 00:42:04 +08:00
Alikia2x
ff0d05542d feature: base64 tool page 2024-05-05 16:28:12 +08:00
Alikia2x
969ed948b5 improve: onesearch relevance display 2024-04-29 15:26:21 +08:00
Alikia2x
e3ec2bb897 page: tools page: base64 2024-04-27 19:00:47 +08:00
Alikia2x
6fe4fc28c1 feature: add log and error report 2024-04-27 17:00:02 +08:00
Alikia2x
1faee002a9 improve: better base64 check, with test 2024-04-27 16:58:37 +08:00
Alikia2x
38b144a6f4 improve: prompt for NLP result 2024-04-26 22:43:47 +08:00
Alikia2x
86d4015ae5 feature: better base64NLP, add copy to clipboard for text 2024-04-26 22:20:49 +08:00
Alikia2x
b46f1adddb improve: base64 NLP 2024-04-26 18:21:52 +08:00
Alikia2x
1d7aaf6a8a feature: base64 detect and auto-decode 2024-04-26 18:11:29 +08:00
Alikia2x
136450f93d feature: base structure of onesearch, no bugs now 2024-04-26 18:10:31 +08:00
Alikia2x
a9cf5630fe feature: complete function and code logic for suggestion (include cloud)
todo: problem: In a short period of time, several functions that depend on the latest state at the same time do operations and update the state according to the state they got, which will cause all calls except the first one to update the state incorrectly due to incorrect dependencies (not-updated state) because of the asynchronous updating of the React state.
2024-04-17 23:44:28 +08:00
Alikia2x
a136c0ab9b preparing: ready to refactor onesearch effect logic 2024-04-13 20:30:20 +08:00
Alikia2x
50c526c095 type: add suggestionItem 2024-04-13 20:23:06 +08:00
Alikia2x
7dd7b70db8 improve: suggestion, modify type decleartion
Suggestion now shows a default search option same as query, this behaviour is same as major browsers.
2024-04-12 21:29:12 +08:00
Alikia2x
f1d22f9d91 improvement: suggestion feature done, adjust i18n language key 2024-04-10 17:34:44 +08:00
Alikia2x
04d72d3ca8 feature: search auto complete API 2024-04-10 00:11:08 +08:00
Alikia2x
8e65767ac1 preparing: browser history search feature 2024-04-06 14:28:41 +08:00
Alikia2x
ced6bffc28 fix: unexpected load settings 2024-04-05 02:20:44 +08:00
Alikia2x
67b5f47440 fixed: hydration error in Time and Background 2024-04-05 01:02:39 +08:00
Alikia2x
7d412ad439 debug: hydration error 2024-04-05 00:09:55 +08:00
Alikia2x
5ca8d32778 fix: nextui tailwind error on docker 2024-04-04 23:02:25 +08:00
Alikia2x
1ab4f1ce63 feature: docker support 2024-04-04 22:51:59 +08:00
Alikia2x
6bcf445848 structure: base components of search suggestions 2024-04-04 21:49:58 +08:00
Alikia2x
8223e696f9 components: Search suggestions 2024-04-04 17:49:03 +08:00
Alikia2x
8a9ef8fcb4 structure: backend API 2024-04-04 13:43:00 +08:00
Alikia2x
b3255de375 interface: right position for engineSelector 2024-04-04 02:39:41 +08:00
Alikia2x
09a7941eca feature: engine change & nextui 2024-04-04 02:26:37 +08:00
Alikia2x
090154115a improve: Time component with props 2024-04-02 01:14:39 +08:00
Alikia2x
0aef1e1641 improvement: text for searchbox placeholder 2024-03-31 17:21:26 +08:00
Alikia2x
c1930d6bd7 imporve: test for validLink 2024-03-31 17:06:05 +08:00
Alikia2x
24de7bcaa3 fix: remove redundant log in validLink and rename 2024-03-31 16:29:51 +08:00
Alikia2x
62a0e85554 test: add test for validLink 2024-03-31 16:26:48 +08:00
Alikia2x
1f5a36832c imporvement: better link check 2024-03-31 16:26:33 +08:00
Alikia2x
09d099a625 structure: add favicon 2024-03-31 14:14:58 +08:00
Alikia2x
a5f5112fc1 feature: directly open URL 2024-03-31 14:02:42 +08:00
Alikia2x
8c005b3769 interface: better theme & state 2024-03-31 01:35:55 +08:00
Alikia2x
9a623164e4 feature: time 2024-03-31 01:07:38 +08:00
Alikia2x
304ea14003 feature: i18n support 2024-03-31 00:56:04 +08:00
93 changed files with 12165 additions and 819 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git

7
.gitignore vendored
View File

@ -35,3 +35,10 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
.syncignore
# log
app.log
# doc
doc/.vitepress/dist
doc/.vitepress/cache

1
.npmrc Normal file
View File

@ -0,0 +1 @@
public-hoist-pattern[]=*@nextui-org/*

67
Dockerfile Normal file
View File

@ -0,0 +1,67 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 4594
ENV PORT 4594
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js

28
app/[locale]/global.css Normal file
View File

@ -0,0 +1,28 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb));
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

39
app/[locale]/layout.tsx Normal file
View File

@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./global.css";
import { NextIntlClientProvider, useMessages } from "next-intl";
import { ThemeProvider } from "next-themes";
import { Providers } from "../providers";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "SparkHome",
description: "Your best browser homepage, with a magic searchbox.",
icons: {
icon: "/favicon.ico"
}
};
export default function LocaleLayout({
children,
params: { locale }
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const messages = useMessages();
return (
<html lang={locale} suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider>
<Providers>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</Providers>
</ThemeProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,11 @@
"use client";
import { useTranslations } from "next-intl";
export default function NotFound() {
const t = useTranslations("404");
return (
<div>
<h1>{t('title')}</h1>
</div>
);
}

View File

@ -1,7 +1,7 @@
"use client";
import { RecoilRoot } from "recoil";
import Homepage from "../components";
import Homepage from "../../components";
export default function Home() {
return (

View File

@ -0,0 +1,82 @@
"use client";
import Switcher from "@/components/switcher";
import Notice from "@/components/tools/notice";
import base64ToHex from "@/lib/base64ToHex";
import copyToClipboard from "@/lib/copy";
import normalizeHex from "@/lib/normalizeHex";
import { validBase64 } from "@/lib/onesearch/baseCheck";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { utoa, atou } from "unicode-encode";
export default function Base64() {
const t = useTranslations("tools");
const [mode, setMode] = useState("Encode");
const [message, setMessage] = useState("");
const [messageResult, setMessageResult] = useState("");
const [isHex, setHex] = useState(false);
const [info, setInfo] = useState("");
const [type, setType] = useState("");
useEffect(() => {
setType("");
setInfo("");
setHex(false);
if (mode == "Encode") {
setMessageResult(utoa(message));
} else {
if (validBase64(message)) {
try {
setMessageResult(atou(message));
} catch (e) {
setMessageResult(normalizeHex(base64ToHex(message)));
setHex(true);
setType("info");
setInfo("Showing HEX result.");
}
} else if (message.trim() !== "") {
setMessageResult("");
setType("warning");
setInfo("Invalid Base64.");
} else {
setMessageResult("");
}
}
}, [mode, message]);
return (
<div>
<h1 className="text-3xl font-semibold">{t("base64.title")}</h1>
<Switcher items={["Encode", "Decode"]} selected={mode} setSelected={setMode} class="mt-4" />
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full h-80 mt-4 p-4 rounded-lg bg-zinc-100 dark:bg-zinc-800 resize-none outline-none duration-200 transition-colors-opacity border-2 border-transparent focus:border-zinc-600 dark:focus:border-zinc-300"
/>
<div className="w-full h-12 mt-4">
<span className="w-fit text-2xl font-bold leading-10">Result:</span>
<button
onClick={() => {
copyToClipboard(messageResult);
setType("info");
setInfo("Copied");
setTimeout(() => {
setInfo("");
setType("");
}, 3000);
}}
className="absolute right-0 w-fit h-10 rounded-md leading-10 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-300 hover:dark:bg-zinc-700 px-5 z-10 cursor-pointer duration-300"
>
Copy
</button>
</div>
<div
className={`empty:py-0 mt-6 w-full h-fit rounded-md leading-10 bg-zinc-100 dark:bg-zinc-800 py-2 px-5 z-10 cursor-pointer duration-100 break-all ${
isHex ? "font-mono" : ""
}`}
>
{messageResult}
</div>
<Notice type={type} info={info} class="mt-4" />
</div>
);
}

View File

@ -0,0 +1,10 @@
export default function ToolsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="h-screen w-screen overflow-x-hidden bg-white dark:bg-[rgb(23,25,29)]">
<main className="relative h-full w-full md:w-3/4 lg:w-1/2 left-0 md:left-[12.5%] lg:left-1/4
pt-12">
{children}
</main>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { completeGoogle } from "search-engine-autocomplete";
import { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get('q')!;
let language = request.nextUrl.searchParams.get('l');
if (language === null) language = 'en-US';
const data = await completeGoogle(query, language);
const completeWord: string | undefined = data.suggestions.filter((s) => {
return s.relativeRelevance > 0.96
})[0]?.suggestion;
return new Response(completeWord ? completeWord : query);
}

8
app/api/error/route.ts Normal file
View File

@ -0,0 +1,8 @@
import { logger } from "@/lib/log";
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const data = await req.json();
logger.warn({type:"client_telemetering",error:data});
return new Response();
}

13
app/api/ping/route.ts Normal file
View File

@ -0,0 +1,13 @@
import { NEXT_API_VERSION, SPARKHOME_VERSION, CLIENT_VERSION } from "@/lib/version";
export const dynamic = "force-dynamic"; // defaults to auto
export async function GET(request: Request) {
const time = new Date().getTime() / 1000;
const responseString =
`SparkHome ${SPARKHOME_VERSION}
Client ${CLIENT_VERSION}
API ${NEXT_API_VERSION}
ServerTime ${time}
Powered by alikia2x (ω< )`;
return new Response(responseString);
}

View File

@ -0,0 +1,14 @@
import { completeGoogle } from "search-engine-autocomplete";
import { NextRequest } from "next/server"
import { suggestionsResponse } from "@/global";
import { logger } from "@/lib/log";
export async function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get('q')!;
const t = parseInt(request.nextUrl.searchParams.get('t') || "0") || null;
let language = request.nextUrl.searchParams.get('l');
if (language === null) language = 'en-US';
const data = await completeGoogle(query, language);
logger.info({ type: "onesearch_search_autocomplete", query: query, data: data });
return new Response(JSON.stringify({...data, time: t} as suggestionsResponse));
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -1,33 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

View File

@ -1,22 +1,7 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./global.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app"
};
export default function RootLayout({
children
}: Readonly<{
children,
}: {
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}) {
return children;
}

9
app/not-found.tsx Normal file
View File

@ -0,0 +1,9 @@
export default function NotFound() {
return (
<html>
<body>
<h1>Not Found</h1>
</body>
</html>
);
}

12
app/providers.tsx Normal file
View File

@ -0,0 +1,12 @@
// app/providers.tsx
'use client'
import {NextUIProvider} from '@nextui-org/react'
export function Providers({children}: { children: React.ReactNode }) {
return (
<NextUIProvider>
{children}
</NextUIProvider>
)
}

View File

@ -1,41 +1,35 @@
import Image from "next/image";
import { useRecoilValue } from "recoil";
import { settingsState } from "./state/settings";
import validUrl from "valid-url";
import validateColor from "validate-color";
"use client";
function Background(props: {
isFocus: boolean;
src: string;
onClick: () => void;
}) {
const settings = useRecoilValue(settingsState);
if (validateColor(props.src)) {
import { useEffect, useState } from "react";
import { useRecoilState } from "recoil";
import { bgFocusState } from "./state/background";
import BackgroundContainer from "./backgroundContainer";
export default function () {
const [isFocus, setFocus] = useRecoilState(bgFocusState);
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
const colorSchemeQueryList = window.matchMedia("(prefers-color-scheme: dark)");
setDarkMode(colorSchemeQueryList.matches ? true : false);
const handleChange = () => {
setDarkMode(colorSchemeQueryList.matches ? true : false);
};
colorSchemeQueryList.addEventListener("change", handleChange);
return () => {
colorSchemeQueryList.removeEventListener("change", handleChange);
};
}, []);
return (
<div
className="w-full h-full fixed object-cover inset-0 duration-200 z-0"
style={{ backgroundColor: props.src }}
onClick={props.onClick}
></div>
<div suppressHydrationWarning>
{darkMode ? (
<BackgroundContainer src="rgb(23,25,29)" isFocus={isFocus} onClick={() => setFocus(false)} darkMode={darkMode}/>
) : (
<BackgroundContainer src="white" isFocus={isFocus} onClick={() => setFocus(false)} darkMode={darkMode}/>
)}
</div>
);
} else if (validUrl.isWebUri(props.src)) {
return (
<Image
src={props.src}
className={
"w-full h-full fixed object-cover inset-0 duration-200 z-0 " +
(props.isFocus
? settings.bgBlur
? "blur-lg scale-110"
: "brightness-50 scale-105"
: "")
}
alt="background"
onClick={props.onClick}
fill={true}
/>
);
}
}
export default Background;

View File

@ -0,0 +1,47 @@
import Image from "next/image";
import { useRecoilValue } from "recoil";
import { settingsState } from "./state/settings";
import validUrl from "valid-url";
import validateColor from "validate-color";
export default function (props: { isFocus: boolean; src: string; darkMode: boolean; onClick: () => void }) {
const settings = useRecoilValue(settingsState);
if (validateColor(props.src)) {
return (
<div
className="w-full h-full fixed object-cover inset-0 duration-200 z-0"
style={{ backgroundColor: props.src }}
onClick={props.onClick}
></div>
);
} else if (validUrl.isWebUri(props.src)) {
return (
<Image
src={props.src}
className={
"w-full h-full fixed object-cover inset-0 duration-200 z-0 " +
(props.isFocus ? (settings.bgBlur ? "blur-lg scale-110" : "brightness-50 scale-105") : "")
}
alt="background"
onClick={props.onClick}
fill={true}
/>
);
} else {
if (props.darkMode) {
return (
<div
className="w-full h-full fixed object-cover inset-0 duration-200 z-0 bg-[rgb(23,25,29)]"
onClick={props.onClick}
></div>
);
} else {
return (
<div
className="w-full h-full fixed object-cover inset-0 duration-200 z-0 bg-white"
onClick={props.onClick}
></div>
);
}
}
}

View File

@ -1,23 +1,35 @@
import { useRecoilState } from "recoil";
import Background from "./background";
"use client";
import { useRecoilValue, useSetRecoilState } from "recoil";
import { settingsState } from "./state/settings";
import Search from "./search/search";
import { bgFocusState } from "./state/background";
import EngineSelector from "./search/engineSelector";
import Onesearch from "./search/onesearch/onesearch";
import dynamic from "next/dynamic";
const Background = dynamic(() => import("./background"), {
ssr: false
});
const Time = dynamic(() => import("./time"), {
ssr: false
});
export default function Homepage() {
const [isFocus, setFocus] = useRecoilState(bgFocusState);
const settings = useRecoilValue(settingsState);
const setFocus = useSetRecoilState(bgFocusState);
return (
<div className="h-full fixed overflow-hidden w-full bg-black">
<Background
src="rgb(23,25,29)"
isFocus={isFocus}
onClick={() => setFocus(false)}
/>
<Search
onFocus={() => {
setFocus(true);
console.log("focus");
}}
<Time showSecond={settings.timeShowSecond} />
<EngineSelector
className="absolute top-20 lg:top-44 short:top-0 translate-x-[-50%] translate-y-[-0.2rem]
left-1/2 w-11/12 sm:w-[700px] text:black text-right
dark:text-white text-3xl text-shadow-lg z-10"
/>
<Background />
<Search onFocus={() => setFocus(true)} />
<Onesearch />
</div>
);
}

View File

@ -0,0 +1,77 @@
"use client";
import React, { SetStateAction, useEffect, useState } from "react";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Button } from "@nextui-org/react";
import { useTranslations } from "next-intl";
import { useRecoilValue, useSetRecoilState } from "recoil";
import { settingsState } from "../state/settings";
import { engineTranslation } from "./translatedEngineList";
import { settingsType } from "@/global";
export default function(
props: { className: string }
) {
const t = useTranslations("Search");
const settings: settingsType = useRecoilValue(settingsState);
const items = settings.searchEngines;
const currentEngine: string = settings.currentSearchEngine;
const displayEngine = getName(currentEngine);
const [selectedKeys, setSelectedKeys] = useState(new Set([currentEngine]) as any);
const selectedValue = React.useMemo(() => Array.from(selectedKeys).join(", "), [selectedKeys]);
const setSettings = useSetRecoilState(settingsState);
function setEngine(engine: string) {
setSettings((oldSettings: settingsType) => {
return {
...oldSettings,
currentSearchEngine: engine
};
});
}
function getName(engineKey: string) {
return engineTranslation.includes(engineKey) ? t(`engine.${engineKey}`) : engineKey;
}
useEffect(() => {
if (selectedValue !== currentEngine) {
setEngine(selectedValue);
}
}, [selectedValue]);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return (
<div className={props.className}>
{
isClient &&
(
<Dropdown>
<DropdownTrigger>
<Button variant="bordered" className="capitalize">
{displayEngine}
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label={t("engine-aria")}
variant="light"
disallowEmptySelection
selectionMode="single"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
{Object.keys(items).map((item) => (
<DropdownItem key={item} suppressHydrationWarning>
{getName(item)}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
)}
</div>
);
}

View File

@ -0,0 +1,26 @@
import { settingsType, suggestionItem } from "@/global";
import copyToClipboard from "@/lib/copy";
import { normalizeURL } from "@/lib/normalizeURL";
import search from "@/lib/search";
export default function (
index: number,
suggestion: suggestionItem[],
query: string,
settings: settingsType,
searchBoxRef: React.RefObject<HTMLInputElement>
) {
const selected = suggestion[index];
const engine = settings.searchEngines[settings.currentSearchEngine];
const newTab = settings.searchInNewTab;
let clipboard: any;
if (selected.type === "QUERY" || selected.type === "default") {
search(selected.suggestion, engine, newTab);
} else if (selected.type === "NAVIGATION" || selected.type === "default-link") {
window.open(normalizeURL(selected.suggestion));
} else if (selected.type === "text") {
console.log("????");
copyToClipboard(selected.suggestion);
searchBoxRef.current?.focus();
}
}

View File

@ -0,0 +1,42 @@
import { suggestionItem } from "@/global";
import { findClosestDateIndex } from "@/lib/weather/getCurrentWeather";
import { getLocationNative } from "@/lib/weather/getLocation";
import { getWeather } from "@/lib/weather/getWeather";
import { WMOCodeTable } from "@/lib/weather/wmocode";
type UpdateSuggestionFunction = (data: suggestionItem[]) => void;
export function handleNLUResult(result: any, updateSuggestion: UpdateSuggestionFunction){
if (result.intent == "weather.summary") {
getLocationNative((data: GeolocationCoordinates | GeolocationPositionError) => {
console.log(data);
if (data instanceof GeolocationCoordinates) {
getWeather(data.latitude, data.longitude).then((weather) => {
console.log(weather["hourly"]);
let hourIndex = findClosestDateIndex(
weather["hourly"]["time"],
weather["utc_offset_seconds"]
);
let temp = weather["hourly"]["apparent_temperature"][hourIndex];
let weatherCode = weather["hourly"]["weather_code"][hourIndex];
console.log(temp, weatherCode, hourIndex);
updateSuggestion([
{
type: "text",
suggestion: `Weather: ${temp}${weather["hourly_units"]["apparent_temperature"]}, ${WMOCodeTable[weatherCode]["day"].description}`,
relevance: 3000 * result.score
}
]);
});
}
});
} else if (result.intent !== "None") {
updateSuggestion([
{
type: "text",
suggestion: result.intent,
relevance: 2200 * result.score
}
]);
}
}

View File

@ -0,0 +1,30 @@
import { normalizeURL } from "@/lib/normalizeURL";
export default function (props: { children: React.ReactNode; query: string; selected: boolean }) {
if (props.selected) {
return (
<div
className={`w-full h-10 leading-10 bg-zinc-300 dark:bg-zinc-700
px-5 z-10 cursor-pointer duration-100`}
onClick={() => {
window.open(normalizeURL(props.query));
}}
>
{props.children}
</div>
);
}
else {
return (
<div
className={`w-full h-10 leading-10 bg-zinc-100 hover:bg-zinc-300
dark:bg-zinc-800 hover:dark:bg-zinc-700 px-5 z-10 cursor-pointer duration-100`}
onClick={() => {
window.open(normalizeURL(props.query));
}}
>
{props.children}
</div>
);
}
}

View File

@ -0,0 +1,180 @@
import { useEffect, useRef, useState } from "react";
import SuggestionBox from "./suggestionBox";
import { useRecoilState, useRecoilValue } from "recoil";
import { queryState } from "@/components/state/query";
import { useLocale, useTranslations } from "next-intl";
import { suggestionItem, suggestionsResponse } from "@/global";
import getSearchEngineName from "@/lib/getSearchEngineName";
import PlainSearch from "./plainSearch";
import { suggestionsState } from "@/components/state/suggestion";
import validLink from "@/lib/url/validLink";
import Link from "./link";
import { selectedSuggestionState } from "@/components/state/suggestionSelection";
import { settingsState } from "@/components/state/settings";
import PlainText from "./plainText";
import { sendError } from "@/lib/telemetering/sendError";
import { NLU } from "@/lib/nlp/load";
import { getLocationNative } from "@/lib/weather/getLocation";
import { getWeather } from "@/lib/weather/getWeather";
import { findClosestDateIndex, getClosestHourTimestamp } from "@/lib/weather/getCurrentWeather";
import { WMOCodeTable } from "@/lib/weather/wmocode";
import { handleNLUResult } from "./handleNLUResult";
export default function () {
const [suggestion, setFinalSuggetsion] = useRecoilState(suggestionsState);
const [location, setLocation] = useState(null);
const [manager, setManager] = useState(null);
const lastRequestTimeRef = useRef(0);
const selected = useRecoilValue(selectedSuggestionState);
const settings = useRecoilValue(settingsState);
const devMode = true;
const query = useRecoilValue(queryState);
const engineName = getSearchEngineName();
const engine = settings.currentSearchEngine;
const lang = useLocale();
const t = useTranslations("Search");
useEffect(() => {
const time = new Date().getTime().toString();
if (query.trim() === "" || query.length > 120) {
cleanSuggestion("QUERY", "NAVIGATION");
return;
}
fetch(`/api/suggestion?q=${query}&l=${lang}&t=${time}&engine=${engine}`)
.then((res) => res.json())
.then((data: suggestionsResponse) => {
try {
let suggestionToUpdate: suggestionItem[] = data.suggestions;
if (data.time > lastRequestTimeRef.current) {
cleanSuggestion("NAVIGATION", "QUERY");
lastRequestTimeRef.current = data.time;
updateSuggestion(suggestionToUpdate);
}
} catch (error: Error | any) {
sendError(error);
}
})
.catch((error) => {
// Handle fetch error
sendError(error);
});
}, [query]);
function updateSuggestion(data: suggestionItem[]) {
setFinalSuggetsion((cur: suggestionItem[]) => {
const types: string[] = [];
for (let sug of data) {
if (!types.includes(sug.type)) types.push(sug.type);
}
for (let type of types) {
cur = cur.filter((item) => {
return item.type !== type;
});
}
return cur.concat(data).sort((a, b) => {
return b.relevance - a.relevance;
});
});
}
function cleanSuggestion(...types: string[]) {
setFinalSuggetsion((suggestion: suggestionItem[]) => {
return suggestion.filter((item) => {
return !types.includes(item.type);
});
});
}
const NLUModel = new NLU();
useEffect(() => {
NLUModel.init().then((nlu) => {
setManager(nlu.manager);
console.log(nlu.manager);
});
}, []);
useEffect(() => {
cleanSuggestion("default-link", "default", "text");
if (validLink(query)) {
updateSuggestion([
{ type: "default-link", suggestion: query, relevance: 3000, prompt: <span>Go to: </span> },
{ type: "default", suggestion: query, relevance: 1600 }
]);
} else {
updateSuggestion([
{
type: "default",
suggestion: query,
relevance: 2000
}
]);
}
if (manager != null) {
// @ts-ignore
manager.process(query).then((result) => {
console.log(result);
handleNLUResult(result, updateSuggestion);
});
}
}, [query, engineName]);
return (
<SuggestionBox>
{suggestion.map((s, i) => {
if (s.suggestion.trim() === "") return;
if (s.type === "default") {
return (
<PlainSearch key={i} query={s.suggestion} selected={i == selected}>
{s.suggestion}&nbsp;
<span className="text-zinc-700 dark:text-zinc-400 text-sm">
{t("search-help-text", { engine: engineName })}
</span>
{devMode && (
<span className="absolute text-zinc-700 dark:text-zinc-400 text-sm leading-10 h-10 right-2">
{s.relevance}
</span>
)}
</PlainSearch>
);
} else if (s.type === "QUERY") {
return (
<PlainSearch key={i} query={s.suggestion} selected={i == selected}>
{s.suggestion}
{devMode && (
<span className="absolute text-zinc-700 dark:text-zinc-400 text-sm leading-10 h-10 right-2">
{s.relevance}
</span>
)}
</PlainSearch>
);
} else if (s.type === "NAVIGATION" || s.type === "default-link") {
return (
<Link key={i} query={s.suggestion} selected={i == selected}>
{s.prompt && <span className="text-zinc-700 dark:text-zinc-400">{s.prompt}</span>}
{s.suggestion}
{devMode && (
<span className="absolute text-zinc-700 dark:text-zinc-400 text-sm leading-10 h-10 right-2">
{s.relevance}
</span>
)}
</Link>
);
} else if (s.type === "text") {
return (
<PlainText key={i} selected={i == selected}>
{s.prompt && <span className="text-zinc-700 dark:text-zinc-400">{s.prompt}</span>}
<p>{s.suggestion}</p>
{devMode && (
<span className="bottom-0 absolute text-zinc-700 dark:text-zinc-400 text-sm leading-10 h-10 right-2">
{s.relevance}
</span>
)}
</PlainText>
);
}
})}
</SuggestionBox>
);
}

View File

@ -0,0 +1,35 @@
import search from "@/lib/search";
import { settingsState } from "@/components/state/settings";
import { useRecoilValue } from "recoil";
export default function (props: { children: React.ReactNode; query: string; selected: boolean }) {
const settings = useRecoilValue(settingsState);
const engine = settings.searchEngines[settings.currentSearchEngine];
const newTab = settings.searchInNewTab;
if (props.selected) {
return (
<div
className={`relative w-full h-10 leading-10 bg-zinc-300 dark:bg-zinc-700
px-5 z-10 cursor-pointer duration-100 truncate`}
onClick={() => {
search(props.query, engine, newTab);
}}
>
{props.children}
</div>
);
}
else {
return (
<div
className={`relative w-full h-10 leading-10 bg-zinc-100 hover:bg-zinc-300
dark:bg-zinc-800 hover:dark:bg-zinc-700 px-5 z-10 cursor-pointer duration-100 truncate`}
onClick={() => {
search(props.query, engine, newTab);
}}
>
{props.children}
</div>
);
}
}

View File

@ -0,0 +1,21 @@
export default function (props: { children: React.ReactNode; selected: boolean }) {
if (props.selected) {
return (
<div
className={`relative w-full h-auto leading-6 break-all py-[0.6rem] bg-zinc-300 dark:bg-zinc-700
px-5 z-10 cursor-pointer duration-100`}
>
{props.children}
</div>
);
} else {
return (
<div
className={`relative w-full h-auto leading-6 break-all py-[0.6rem] bg-zinc-100 hover:bg-zinc-300
dark:bg-zinc-800 hover:dark:bg-zinc-700 px-5 z-10 cursor-pointer duration-100`}
>
{props.children}
</div>
);
}
}

View File

@ -0,0 +1,6 @@
export default function(props: { children: React.ReactNode }) {
return (
<div dangerouslySetInnerHTML={{ __html: `<p>${props.children}</p>` as string }} className={`relative w-full h-10 leading-10 bg-zinc-100 hover:bg-zinc-300 dark:bg-zinc-800 hover:dark:bg-zinc-700 px-5 z-10 cursor-pointer duration-100`}>
</div>
);
}

View File

@ -0,0 +1,9 @@
export default function(props: { children?: React.ReactNode }) {
return (
<div className={`relative bg-zinc-100 dark:bg-zinc-800 w-11/12 sm:w-[700px] h-auto max-h-[calc(100vh-20rem)]
overflow-y-auto left-1/2 translate-x-[-50%] top-72 z-20 rounded-md overflow-hidden duration-250
${props.children ? "opacity-100" : "opacity-0"}`}>
{props.children}
</div>
);
}

View File

@ -1,24 +1,66 @@
import { atom, useRecoilValue } from "recoil";
import { settingsState } from "../state/settings";
"use client";
import { useRecoilState, useRecoilValue } from "recoil";
import { settingsState } from "../state/settings";
import { useTranslations } from "next-intl";
import { queryState } from "../state/query";
import { settingsType } from "@/global";
import handleEnter from "./onesearch/handleEnter";
import { selectedSuggestionState } from "../state/suggestionSelection";
import { suggestionsState } from "../state/suggestion";
import { KeyboardEvent, useRef } from "react";
export default function Search(props: { onFocus: () => void }) {
const settings: settingsType = useRecoilValue(settingsState);
const t = useTranslations("Search");
const [query, setQuery] = useRecoilState(queryState);
const [selectedSuggestion, setSelected] = useRecoilState(selectedSuggestionState);
const suggestions = useRecoilValue(suggestionsState);
const searchBoxRef = useRef<HTMLInputElement>(null);
export default function Search(props: {
onFocus: () => void;
}) {
const settings = useRecoilValue(settingsState);
let style = "default";
function handleKeydown(e: KeyboardEvent) {
if (e.key == "Enter") {
e.preventDefault();
handleEnter(selectedSuggestion, suggestions, query, settings, searchBoxRef);
return;
} else if (e.key == "ArrowUp") {
e.preventDefault();
const len = suggestions.length;
setSelected((selectedSuggestion - 1 + len) % len);
} else if (e.key == "ArrowDown") {
e.preventDefault();
const len = suggestions.length;
setSelected((selectedSuggestion + 1) % len);
}
}
if (style === "default") {
return (
// 祖传样式,勿动
<div className="absolute w-full top-[8.5rem] lg:top-56 short:top-24 z-1 left-1/2 translate-x-[-50%] ">
<input
className="absolute z-1 w-11/12 sm:w-[700px] h-10 rounded-lg left-1/2 translate-x-[-50%]
text-center outline-none border-[1px] focus:border-2 duration-200 pr-2 shadow-lg bg-white dark:bg-[rgb(23,25,29)]
text-center outline-none border-[1px] focus:border-2 duration-200 pr-2 shadow-md focus:shadow-none dark:shadow-zinc-800 dark:shadow-md bg-white dark:bg-[rgb(23,25,29)]
dark:border-neutral-500 dark:focus:border-neutral-300 placeholder:text-slate-500
dark:placeholder:text-slate-400 text-slate-900 dark:text-white"
id="searchBox"
type="text"
placeholder="Search"
placeholder={t("placeholder")}
onFocus={props.onFocus}
onKeyDown={handleKeydown}
onChange={(e) =>
setQuery((_) => {
return e.target.value;
})
}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
ref={searchBoxRef}
value={query}
/>
</div>
);
@ -30,7 +72,7 @@ export default function Search(props: {
className={
`absolute z-1 w-2/3 sm:w-80 md:w-[400px] focus:w-11/12 focus:sm:w-[700px] hover:w-11/12
hover:sm:w-[700px] h-10 rounded-3xl left-1/2 translate-x-[-50%] text-center outline-none
border-solid border-0 duration-200 pr-2 shadow-lg` +
border-solid border-0 duration-200 pr-2 shadow-md focus:shadow-none` +
(settings.bgBlur
? `bg-[rgba(255,255,255,0.5)] dark:bg-[rgba(24,24,24,0.75)] backdrop-blur-xl
placeholder:text-slate-500 dark:placeholder:text-slate-400 text-slate-900 dark:text-white`
@ -39,8 +81,9 @@ export default function Search(props: {
}
id="searchBox"
type="text"
placeholder="Search"
placeholder={t("placeholder")}
onFocus={props.onFocus}
ref={searchBoxRef}
/>
</div>
);

View File

@ -0,0 +1 @@
export const engineTranslation = ["google", "bing", "baidu", "duckduckgo", "yandex", "ecosia", "yahoo"];

10
components/state/query.ts Normal file
View File

@ -0,0 +1,10 @@
import { atom } from "recoil";
const queryState = atom({
key: "searchQuery",
default: ""
});
export {
queryState,
}

View File

@ -1,14 +1,58 @@
import { atom, selector } from "recoil";
import { settingsType } from "@/global";
import isLocalStorageAvailable from "@/lib/isLocalStorageAvailable";
import { atom } from "recoil";
const defaultSettings: settingsType = {
"version": 2,
"elementBackdrop": true,
"bgBlur": true,
"timeShowSecond": false,
"currentSearchEngine": "google",
"searchInNewTab": true,
"searchEngines": {
"google": "https://www.google.com/search?q=%s",
"bing": "https://www.bing.com/search?q=%s",
"baidu": "https://www.baidu.com/s?wd=%s",
"duckduckgo": "https://duckduckgo.com/?q=%s",
"yandex": "https://yandex.com/search/?text=%s",
"yahoo": "https://search.yahoo.com/search?p=%s",
"ecosia": "https://www.ecosia.org/search?q=%s"
}
};
const localStorageEffect =
(key: string) =>
({ setSelf, onSet }: any) => {
if (isLocalStorageAvailable()===false){
return;
}
if (localStorage.getItem(key) === null) {
localStorage.setItem(key, JSON.stringify(defaultSettings));
return;
}
let settings =JSON.parse(JSON.stringify(defaultSettings));
const savedSettings = localStorage.getItem(key)!;
const parsedSettings = JSON.parse(savedSettings);
Object.keys(settings).map((key) => {
if (parsedSettings[key] !== undefined && key !== "version"){
settings[key] = parsedSettings[key];
}
})
setSelf(settings);
localStorage.setItem(key, JSON.stringify(settings));
onSet((newValue: settingsType) => {
localStorage.setItem(key, JSON.stringify(newValue));
});
};
const settingsState = atom({
key: "settings",
default: {
version: 1,
elementBackdrop: true,
bgBlur: true
}
default: defaultSettings,
effects_UNSTABLE: [localStorageEffect("settings")]
});
export {
settingsState,
}
export { settingsState };

View File

@ -0,0 +1,11 @@
import { suggestionItem } from "@/global";
import { atom } from "recoil";
const suggestionsState = atom({
key: "oneSearchSuggestions",
default: [] as suggestionItem[]
});
export {
suggestionsState,
}

View File

@ -0,0 +1,10 @@
import { atom } from "recoil";
const selectedSuggestionState = atom({
key: "selectedSuggestion",
default: 0
});
export {
selectedSuggestionState,
}

31
components/switcher.tsx Normal file
View File

@ -0,0 +1,31 @@
'use client';
import React, { useState, useEffect, useRef } from "react";
export default function Switcher(props: { items: string[]; selected: string, setSelected: Function, class?: string }) {
const selectedRef = useRef(null);
const [selectedCoordinate, setSelectedCoordinate] = useState(0);
const [selectedWidth, setSelectedWidth] = useState(0);
useEffect(() => {
if (selectedRef.current){
setSelectedCoordinate((selectedRef.current as HTMLElement)?.offsetLeft);
setSelectedWidth((selectedRef.current as HTMLElement)?.getBoundingClientRect().width);
}
}, [props.selected]);
return (
<div className={`relative w-fit h-12 px-1 flex rounded-lg bg-zinc-100 dark:bg-zinc-800 z-0 ${props.class}`}>
{props.items.map((item, index) => (
<div
key={index}
className="relative mt-[0.375rem] rounded-md w-fit h-9 leading-9 px-4 mx-1 z-20 cursor-pointer duration-100"
ref={item == props.selected ? selectedRef : null}
onClick={() => props.setSelected(item)}
>
{item}
</div>
))}
<div className="absolute mt-[0.375rem] rounded-md h-9 bg-zinc-300 dark:bg-zinc-600 z-10 duration-250 ease-[cubic-bezier(.15,.16,.2,1.2)]" style={{ left: selectedCoordinate, width: selectedWidth }}></div>
</div>
);
}

50
components/time.tsx Normal file
View File

@ -0,0 +1,50 @@
"use client";
import React, { useState, useEffect } from "react";
import { useFormatter } from "next-intl";
export default function Time(props: {
showSecond: boolean
}) {
const [currentTime, setCurrentTime] = useState(new Date());
const format = useFormatter();
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 150);
return () => {
clearInterval(timer);
};
}, []);
const formatTime = () => {
const hours = currentTime.getHours().toString().padStart(2, "0");
const minutes = currentTime.getMinutes().toString().padStart(2, "0");
const seconds = currentTime.getSeconds().toString().padStart(2, "0");
if (props.showSecond) {
return `${hours}:${minutes}:${seconds}`;
} else {
return `${hours}:${minutes}`;
}
};
return (
<div
className="absolute top-20 lg:top-44 short:top-0 translate-x-[-50%]
left-1/2 w-11/12 sm:w-[700px] text:black
dark:text-white text-3xl text-left text-shadow-lg z-10"
>
{formatTime()}{" "}
<span className="text-lg leading-9 relative">
{format.dateTime(currentTime, {
year: "numeric",
month: "short",
day: "numeric"
})}
</span>
</div>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Icon } from "@iconify-icon/react";
const typeToColor: Record<string, string> = {
success: "bg-green-500",
info: "bg-blue-500",
warning: "bg-orange-500",
error: "bg-red-500"
};
const typeToIcon: Record<string, string> = {
success: "material-symbols:check-circle",
info: "material-symbols:info",
warning: "material-symbols:warning",
error: "material-symbols:error"
};
export default function Notice(props: { type: string; info: string; class?: string }) {
if (props.type && props.info)
return (
<div
className={`relative ${props.class} ${
typeToColor[props.type]
} rounded-md w-full min-h-12 h-fit empty:px-0 px-4 z-20 cursor-pointer duration-100 `}
>
<Icon className="text-2xl mt-3" icon={typeToIcon[props.type]} />
<span className="absolute text-base mt-3 ml-1">{props.info}</span>
</div>
);
}

40
doc/.vitepress/config.mts Normal file
View File

@ -0,0 +1,40 @@
import { defineConfig } from "vitepress";
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "SparkHome",
description: "The official documentation of SparkHome",
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
nav: [
{ text: "Home", link: "/" },
{ text: "Examples", link: "/markdown-examples" }
],
sidebar: [
{
text: "Examples",
items: [
{ text: "Markdown Examples", link: "/markdown-examples" },
{ text: "Runtime API Examples", link: "/api-examples" }
]
}
],
socialLinks: [{ icon: "github", link: "https://github.com/alikia2x/sparkhome" }],
logo: "/favicon.ico"
},
locales: {
root: {
label: "English",
lang: "en"
},
zh: {
label: "中文",
lang: "zh-CN",
link: "/zh/"
}
},
head: [['link', { rel: 'icon', href: '/favicon.ico' }]]
});

View File

@ -0,0 +1,118 @@
<script setup>
import DefaultTheme from 'vitepress/theme'
import { useData } from 'vitepress'
import { ref } from 'vue'
const currentLanguage = ref("");
const rootPage = ref("")
const { lang, localeIndex, site } = useData()
if (localeIndex.value === "root") {
rootPage.value = "/"
} else {
if (site.value.locales[localeIndex.value].link != undefined) {
rootPage.value = site.value.locales[localeIndex.value].link
}
else {
rootPage.value = "/" + localeIndex.value + "/"
}
}
currentLanguage.value = lang.value;
console.log(currentLanguage);
const i18nStrings = {
"zh-CN": {
notFoundTitle: "页面未找到",
notFoundQuote: "抱歉,但我们无法找到对应的页面。",
notFoundActionLink: "回到主页",
},
"en": {
notFoundTitle: "PAGE NOT FOUND",
notFoundQuote: "Sorry, the page you are looking for does not exist.",
notFoundActionLink: "Go Home",
}
};
const { Layout } = DefaultTheme
</script>
<template>
<Layout>
<template #not-found>
<div class="notFound">
<p class="NotFoundCode">404</p>
<h1 class="NotFoundTitle">{{ i18nStrings[currentLanguage].notFoundTitle }}</h1>
<div class="NotFoundDivider"></div>
<blockquote class="NotFoundQuote">
<p>
{{ i18nStrings[currentLanguage].notFoundQuote }}
</p>
</blockquote>
<div class="NotFoundAction">
<a class="NotFoundLink" :href="rootPage" :aria-label="i18nStrings[currentLanguage].notFoundActionLink">
{{ i18nStrings[currentLanguage].notFoundActionLink }}
</a>
</div>
</div>
</template>
</Layout>
</template>
<style>
.NotFoundCode {
font-size: 4rem;
line-height: 4rem;
font-weight: 600;
}
.NotFoundTitle {
padding-top: 12px;
letter-spacing: 2px;
line-height: 20px;
font-size: 20px;
font-weight: 700;
}
.NotFoundDivider {
margin: 24px auto 18px;
width: 64px;
height: 1px;
background-color: var(--vp-c-divider);
}
.notFound {
text-align: center;
padding: 64px 24px 96px;
}
.NotFoundQuote {
margin: 0 auto;
max-width: 256px;
font-size: 14px;
font-weight: 500;
color: rgba(60, 60, 67, .78);
}
.NotFoundLink {
display: inline-block;
border: 1px solid var(--vp-c-brand-1);
border-radius: 16px;
padding: 3px 16px;
font-size: 14px;
font-weight: 500;
color: var(--vp-c-brand-1);
transition: border-color .25s, color .25s;
}
.NotFoundAction{
padding-top: 20px;
}
@media (min-width: 768px) {
.notFound {
padding: 96px 32px 168px;
}
}
</style>

View File

@ -0,0 +1,18 @@
// https://vitepress.dev/guide/custom-theme
import { h } from "vue";
import type { Theme } from "vitepress";
import DefaultTheme from "vitepress/theme";
import "./style.css";
import NotFoundLayout from "./NotFoundLayout.vue";
export default {
extends: DefaultTheme,
Layout() {
return h(DefaultTheme.Layout, null, {
"not-found": () => h(NotFoundLayout)
});
},
enhanceApp({ app, router, siteData }) {
// ...
}
} satisfies Theme;

View File

@ -0,0 +1,139 @@
/**
* Customize default theme styling by overriding CSS variables:
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
*/
/**
* Colors
*
* Each colors have exact same color scale system with 3 levels of solid
* colors with different brightness, and 1 soft color.
*
* - `XXX-1`: The most solid color used mainly for colored text. It must
* satisfy the contrast ratio against when used on top of `XXX-soft`.
*
* - `XXX-2`: The color used mainly for hover state of the button.
*
* - `XXX-3`: The color for solid background, such as bg color of the button.
* It must satisfy the contrast ratio with pure white (#ffffff) text on
* top of it.
*
* - `XXX-soft`: The color used for subtle background such as custom container
* or badges. It must satisfy the contrast ratio when putting `XXX-1` colors
* on top of it.
*
* The soft color must be semi transparent alpha channel. This is crucial
* because it allows adding multiple "soft" colors on top of each other
* to create a accent, such as when having inline code block inside
* custom containers.
*
* - `default`: The color used purely for subtle indication without any
* special meanings attched to it such as bg color for menu hover state.
*
* - `brand`: Used for primary brand colors, such as link text, button with
* brand theme, etc.
*
* - `tip`: Used to indicate useful information. The default theme uses the
* brand color for this by default.
*
* - `warning`: Used to indicate warning to the users. Used in custom
* container, badges, etc.
*
* - `danger`: Used to show error, or dangerous message to the users. Used
* in custom container, badges, etc.
* -------------------------------------------------------------------------- */
:root {
--vp-c-default-1: var(--vp-c-gray-1);
--vp-c-default-2: var(--vp-c-gray-2);
--vp-c-default-3: var(--vp-c-gray-3);
--vp-c-default-soft: var(--vp-c-gray-soft);
--vp-c-brand-1: var(--vp-c-indigo-1);
--vp-c-brand-2: var(--vp-c-indigo-2);
--vp-c-brand-3: var(--vp-c-indigo-3);
--vp-c-brand-soft: var(--vp-c-indigo-soft);
--vp-c-tip-1: var(--vp-c-brand-1);
--vp-c-tip-2: var(--vp-c-brand-2);
--vp-c-tip-3: var(--vp-c-brand-3);
--vp-c-tip-soft: var(--vp-c-brand-soft);
--vp-c-warning-1: var(--vp-c-yellow-1);
--vp-c-warning-2: var(--vp-c-yellow-2);
--vp-c-warning-3: var(--vp-c-yellow-3);
--vp-c-warning-soft: var(--vp-c-yellow-soft);
--vp-c-danger-1: var(--vp-c-red-1);
--vp-c-danger-2: var(--vp-c-red-2);
--vp-c-danger-3: var(--vp-c-red-3);
--vp-c-danger-soft: var(--vp-c-red-soft);
}
/**
* Component: Button
* -------------------------------------------------------------------------- */
:root {
--vp-button-brand-border: transparent;
--vp-button-brand-text: var(--vp-c-white);
--vp-button-brand-bg: var(--vp-c-brand-3);
--vp-button-brand-hover-border: transparent;
--vp-button-brand-hover-text: var(--vp-c-white);
--vp-button-brand-hover-bg: var(--vp-c-brand-2);
--vp-button-brand-active-border: transparent;
--vp-button-brand-active-text: var(--vp-c-white);
--vp-button-brand-active-bg: var(--vp-c-brand-1);
}
/**
* Component: Home
* -------------------------------------------------------------------------- */
:root {
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: -webkit-linear-gradient(
120deg,
#bd34fe 30%,
#41d1ff
);
--vp-home-hero-image-background-image: linear-gradient(
-45deg,
#bd34fe 50%,
#47caff 50%
);
--vp-home-hero-image-filter: blur(44px);
}
@media (min-width: 640px) {
:root {
--vp-home-hero-image-filter: blur(56px);
}
}
@media (min-width: 960px) {
:root {
--vp-home-hero-image-filter: blur(68px);
}
}
/**
* Component: Custom Block
* -------------------------------------------------------------------------- */
:root {
--vp-custom-block-tip-border: transparent;
--vp-custom-block-tip-text: var(--vp-c-text-1);
--vp-custom-block-tip-bg: var(--vp-c-brand-soft);
--vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
}
/**
* Component: Algolia
* -------------------------------------------------------------------------- */
.DocSearch {
--docsearch-primary-color: var(--vp-c-brand-1) !important;
}

BIN
doc/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
doc/img/homepage.dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

BIN
doc/img/homepage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

32
doc/index.md Normal file
View File

@ -0,0 +1,32 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: "SparkHome"
text: "The final browser homepage."
tagline: Everything you want, in a magic searchbox.
actions:
- theme: brand
text: Get started
link: /intro
- theme: alt
text: Doc
link: /ref
---
<script setup>
import { ref } from 'vue'
import { useData } from 'vitepress'
const { isDark } = useData();
const dark = ref(isDark);
</script>
# Why SparkHome?
## Simple UI
SparkHome's intuitive design creates a seamless user experience, allowing you to effortlessly access all the information and resources you need from one place. The sleek and modern interface features a simple search box that becomes your one-stop place for navigating the web, getting answers to questions, and leveling up your productivity with our powerful toolbox.
<img src="./img/homepage.dark.png" v-if="isDark">
<img src="./img/homepage.png" v-else>

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 KiB

BIN
doc/zh/img/homepage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

32
doc/zh/index.md Normal file
View File

@ -0,0 +1,32 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: "星火主页"
text: "你的终浏览器主页."
tagline: 想要的一切,尽在一个搜索框中
actions:
- theme: brand
text: 即刻开始
link: ./intro
- theme: alt
text: 文档
link: ./ref
---
<script setup>
import { ref } from 'vue'
import { useData } from 'vitepress'
const { isDark } = useData();
const dark = ref(isDark);
</script>
# 我们的优势
## 极简的界面
SparkHome 的直观设计打造出无缝的用户体验,让你可以毫不费力地从一个地方访问到所有需要的信息和资源。其精致现代的界面设计了一个简易的搜索框,成为您一站式解决网络导航、获取问题的答案和利用我们强大的工具箱提高生产力的地方。
<img src="./img/homepage.dark.png" v-if="isDark">
<img src="./img/homepage.png" v-else>

32
global.d.ts vendored
View File

@ -1,3 +1,31 @@
type settings = {
bgBlur: boolean
import { Suggestion } from "search-engine-autocomplete";
interface settingsType extends Object{
"version": number,
"elementBackdrop": boolean,
"bgBlur": boolean,
"timeShowSecond": boolean,
"currentSearchEngine": string,
"searchInNewTab": boolean,
"searchEngines": {
[key: string]: string
},
};
interface suggestionsResponse extends Object{
suggestions: Suggestion[],
query: string,
verbatimRelevance: number,
time: number
}
type suggestionItem = {
suggestion: string,
type: string,
relativeRelevance?: number,
relevance: number,
prompt?: string | React.ReactElement,
intention?: string | null,
probability?: number,
confidence?: number,
}

14
i18n.ts Normal file
View File

@ -0,0 +1,14 @@
import { notFound } from "next/navigation";
import { getRequestConfig } from "next-intl/server";
// Can be imported from a shared config
const locales = ["en-US", "zh-CN"];
export default getRequestConfig(async ({ locale }) => {
// Validate that the incoming `locale` parameter is valid
if (!locales.includes(locale as any)) notFound();
return {
messages: (await import(`/messages/${locale}.json`)).default
};
});

18
jest.config.ts Normal file
View File

@ -0,0 +1,18 @@
import type { Config } from 'jest'
import nextJest from 'next/jest.js'
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
})
// Add any custom config to be passed to Jest
const config: Config = {
coverageProvider: 'v8',
testEnvironment: 'jsdom',
// Add more setup options before each test is run
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config)

15
lib/base64ToHex.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* Converts a base64 string to a hexadecimal string.
*
* @param {string} base64String - The base64 string to convert.
* @return {string} The hexadecimal representation of the base64 string.
*/
export default function base64ToHex(base64String: string): string {
const raw = atob(base64String);
let result = "";
for (let i = 0; i < raw.length; i++) {
const hex = raw.charCodeAt(i).toString(16);
result += hex.length === 2 ? hex : "0" + hex;
}
return result.toUpperCase();
}

View File

@ -0,0 +1,81 @@
SELECT processed_url, title,
timeK * 4.4 + (visitK + typeK) * 6.5 + typeK * 0.22 + relativeK AS searchRank
FROM (
SELECT *,
CASE
WHEN processed_url LIKE '[query]%' THEN
CASE
WHEN (title like '% [query]%' or title like '[query]%') THEN 12.5
ELSE 12.3
END
ELSE
CASE
WHEN processed_url LIKE '%[query]%' THEN 2.5
ELSE
CASE
WHEN (title like '% [query]%' or title like '[query]%') THEN 1.6
ELSE -1
END
END
END AS relativeK
FROM (
SELECT *,
(1 / (5.2 * LOG(38, 0.000015 * (strftime('%s', 'now') - last_visit_time / 1000000 - (strftime('%s', '1601-01-01'))) + 1) + 1)) AS timeK,
(1 / (-1 * ln(5 * visit_count + 2.71828)) + 1.07) AS visitK,
(1 / (-1 * ln(7 * typed_count + 2.71828)) + 1.12) AS typeK,
CASE
WHEN INSTR(url, '://') > 0 THEN
CASE
WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), 'www.') = 1 THEN SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), INSTR(SUBSTR(url, INSTR(url, '://') + 3), '.') + 1)
ELSE SUBSTR(url, INSTR(url, '://') + 3)
END
ELSE
CASE
WHEN INSTR(url, 'www.') = 1 THEN SUBSTR(url, INSTR(url, '.') + 1)
ELSE url
END
END AS processed_url
FROM urls
) AS subquery where relativeK > 0 and hidden <> 1
) AS subquery
group by processed_url
ORDER BY searchRank DESC
LIMIT 9
;
SELECT processed_url, title,
timeK * 4.4 + (visitK + typeK) * 6.5 + typeK * 0.22 + relativeK AS searchRank
FROM (
SELECT *,
CASE
WHEN processed_url LIKE '[query]%' THEN -1
ELSE
CASE
WHEN (title like '% [query]%' or title like '[query]%') THEN 1.6
ELSE -1
END
END AS relativeK
FROM (
SELECT *,
(1 / (5.2 * LOG(38, 0.000015 * (strftime('%s', 'now') - last_visit_time / 1000000 - (strftime('%s', '1601-01-01'))) + 1) + 1)) AS timeK,
(1 / (-1 * ln(5 * visit_count + 2.71828)) + 1.07) AS visitK,
(1 / (-1 * ln(7 * typed_count + 2.71828)) + 1.12) AS typeK,
CASE
WHEN INSTR(url, '://') > 0 THEN
CASE
WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), 'www.') = 1 THEN SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), INSTR(SUBSTR(url, INSTR(url, '://') + 3), '.') + 1)
ELSE SUBSTR(url, INSTR(url, '://') + 3)
END
ELSE
CASE
WHEN INSTR(url, 'www.') = 1 THEN SUBSTR(url, INSTR(url, '.') + 1)
ELSE url
END
END AS processed_url
FROM urls
) AS subquery where hidden <> 1 and relativeK > 0
) AS subquery
group by processed_url
ORDER BY searchRank DESC
LIMIT 6
;

View File

@ -0,0 +1,40 @@
select
*
from
moz_places
where
rev_host like '%amgif.www.'
or rev_host like '%amgif.'
group by
rev_host
order by
sum(frecency) desc
limit
8;
select
*
from
moz_places
where
rev_host like '%amgif%'
or url like '%figma%'
group by
rev_host
order by
sum(frecency) desc
limit
4;
select
*
from
moz_places
where
title like '%figma%'
group by
rev_host
order by
sum(frecency) desc
limit
3;

8
lib/copy.ts Normal file
View File

@ -0,0 +1,8 @@
export default function copyToClipboard(value: string){
const textarea = document.createElement("textarea");
textarea.value = value;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
};

View File

@ -0,0 +1,17 @@
import { engineTranslation } from "@/components/search/translatedEngineList";
import { settingsState } from "@/components/state/settings";
import { settingsType } from "@/global";
import { useTranslations } from "next-intl";
import { useRecoilValue } from "recoil";
export default function(){
const settings: settingsType = useRecoilValue(settingsState);
const currentEngine = settings.currentSearchEngine;
const displayEngine = getName(currentEngine);
return displayEngine;
}
function getName(engineKey: string) {
const t = useTranslations("Search");
return engineTranslation.includes(engineKey) ? t(`engine.${engineKey}`) : engineKey;
}

View File

@ -0,0 +1,10 @@
export default function(){
var test = 'test';
try {
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch(e) {
return false;
}
}

31
lib/loadSettings.ts Normal file
View File

@ -0,0 +1,31 @@
'use client';
import isLocalStorageAvailable from "./isLocalStorageAvailable";
const defaultSettings = {
version: 1,
elementBackdrop: true,
bgBlur: true,
timeShowSecond: false,
currentSearchEngine: "google",
searchEngines: {
"google": "https://www.google.com/search?q=%s",
"bing": "https://www.bing.com/search?q=%s",
"baidu": "https://www.baidu.com/s?wd=%s",
"duckduckgo": "https://duckduckgo.com/?q=%s",
"yandex": "https://yandex.com/search/?text=%s",
}
}
export default function (setSettings: any) {
if (isLocalStorageAvailable()===false){
return;
}
if (localStorage.getItem("settings") === null) {
localStorage.setItem("settings", JSON.stringify(defaultSettings));
return;
}
const localSettings = JSON.parse(localStorage.getItem("settings") as string);
setSettings(localSettings);
}

10
lib/log.ts Normal file
View File

@ -0,0 +1,10 @@
import pino from "pino";
export const logger = pino({
level: process.env.PINO_LOG_LEVEL || "info",
formatters: {
level: (label) => {
return { level: label.toUpperCase() };
}
}
},pino.destination(`./app.log`));

32
lib/nlp/base.ts Normal file
View File

@ -0,0 +1,32 @@
import { NLPResult } from "../onesearch/NLPResult";
import { stopwords } from "./stopwords";
export class NLP {
result: NLPResult;
constructor(
public query: String,
public task: String,
public intentionKeywords?: String[],
) {
this.result = new NLPResult();
}
public removeStopwords(extraStopwords: string[] = [], disableDefault: boolean = false){
const list = disableDefault ? extraStopwords : stopwords.concat(extraStopwords);
if (list.includes(this.query.trim())) {
this.query = "";
}
for (let word of list){
this.query = this.query.replace(new RegExp(`\\b${word}\\b`, 'gi'), '');
}
}
public extractSlots(str: string, useNER = false): string[]{
const slots: string[] = [];
return slots;
}
public trim() {
this.query = this.query.trim();
const wordList = this.query.split(" ").filter(word => word !== "");
this.query = wordList.join(" ");
}
}

134
lib/nlp/data/en.json Normal file
View File

@ -0,0 +1,134 @@
{
"weather.summary": [
"how's the weather",
"What's going on with the weather?",
"Can you give me an update on the weather?",
"How's the forecast looking today?",
"Give me a summary of the current weather.",
"Can you tell me the current weather?",
"What is the weather situation at the moment?",
"Could you provide a quick weather update?",
"Is it raining or sunny outside?",
"What's the weather like right now?",
"Tell me the current weather conditions.",
"How about the weather today?",
"Is it a good day to be outside?",
"What should I expect in terms of weather today?",
"Is there any severe weather to be aware of?",
"Can you summarize today's weather forecast?",
"What's the weather looking like for the next few hours?",
"Is it going to stay this way all day?",
"Could you give me a brief overview of the weather?",
"What's the general weather situation in our area?",
"Is it cloudy or clear outside?",
"Any weather alerts I should know about?",
"How's the weather looking for outdoor activities?",
"What's the forecast saying for today's weather?",
"Is it going to be a warm day?",
"Are we expecting any storms today?",
"What's the weather condition outside my window?",
"Is it a typical day for this season in terms of weather?",
"how's the weather now?"
],
"weather.temp": [
"What's the temperature like right now?",
"Can you tell me the current temperature?",
"How hot is it outside?",
"What's the temperature supposed to be today?",
"What is the current temp outside?",
"Could you tell me the outdoor temperature?",
"Is it cold or warm outside?",
"What's the high temperature for today?",
"What's the low temperature expected tonight?",
"How does the temperature feel outside?",
"Is it going to get warmer or cooler today?",
"What's the temperature in the shade?",
"Can you provide the current temp in Celsius?",
"What's the temperature in Fahrenheit right now?",
"Is it too hot to be outside?",
"What's the temperature like in the morning?",
"How about the temperature in the evening?",
"Is it warm enough to go swimming?",
"What's the temperature in the city center?",
"Can you tell me the temp in the nearby area?",
"Is it below freezing outside?",
"What's the average temperature for today?",
"Is the temperature dropping or rising?",
"What should I wear considering the temperature?"
],
"base64.encode": [
"Please encode this data with base64: %s",
"I need to encode the following data in base64: %s",
"Could you encode this string using base64? %s",
"Convert this data to b64 encoding: %s",
"I want to encode this information with base64: %s",
"Help me encode this in base64: %s",
"Can you encode this data to base64 format? %s",
"b64 encode",
"base64 encode",
"encode base64 %s"
],
"base64.decode": [
"Please decode this base64 data: %s",
"I have a base64 encoded string that needs decoding: %s",
"Could you decode this base64 string for me? %s",
"Convert this base64 encoded data back to its original form: %s",
"I need to decode this base64 information: %s",
"Help me decode this base64 data: %s",
"Can you translate this base64 back to normal text? %s",
"b64 decode",
"base64 decode",
"decode base64 %s"
],
"url.encode": [
"Please encode this URL: %s",
"I need to encode this URL component: %s",
"Could you encode this part of the URL? %s",
"Convert this URL to its encoded form: %s",
"I want to encode this URL for safe transmission: %s",
"Help me encode this URL segment: %s",
"Can you encode this URL data? %s"
],
"url.decode": [
"Please decode this URL: %s",
"I have an encoded URL that needs decoding: %s",
"Could you decode this URL for me? %s",
"Convert this encoded URL back to its original form: %s",
"I need to decode this URL component: %s",
"Help me decode this URL segment: %s",
"Can you translate this encoded URL back to normal? %s"
],
"html.encode": [
"Please encode this HTML entity: %s",
"I need to encode this text to HTML entity: %s",
"Could you encode this as an HTML entity? %s",
"Convert this text to HTML entity encoding: %s",
"I want to encode this to prevent HTML interpretation: %s",
"Help me encode this into HTML entity: %s",
"Can you encode this for HTML usage? %s"
],
"html.decode": [
"Please decode this HTML entity: %s",
"I have an HTML entity that needs decoding: %s",
"Could you decode this HTML entity for me? %s",
"Convert this HTML entity back to its original text: %s",
"I need to decode this HTML entity to plain text: %s",
"Help me decode this HTML entity: %s",
"Can you translate this HTML entity back to normal text? %s"
],
"None": [
"free weather api",
"js get timezone",
"how",
"how's",
"how's the"
]
}

124
lib/nlp/data/zh.json Normal file
View File

@ -0,0 +1,124 @@
{
"weather.summary": [
"天气如何",
"现在的天气",
"今天的天气预报",
"现在的天气状况",
"今天天气怎么样",
"目前是什么天气",
"今天的天气概述",
"当前天气状况如何",
"今天会下雨吗",
"今天会下雪吗",
"今天晴天吗",
"今天的天气状况如何",
"现在外面是什么天气",
"今天天气好么",
"今天适合外出吗",
"今天的天气适宜做什么",
"今天有没有雾霾",
"今天的空气质量如何",
"今天的紫外线指数是多少",
"今天有没有大风",
"今天会不会很冷",
"今天的天气会变化吗",
"今天晚上的天气如何",
"今天夜里会下雨吗",
"今天的天气对出行有影响吗",
"今天的天气对运动有影响吗",
"今天的天气对工作有影响吗",
"今天的天气对旅游有影响吗",
"今天的天气对健康有影响吗"
],
"weather.temp": [
"现在的温度",
"现在多少度",
"外面有多热",
"明天热不热?",
"现在的气温是多少",
"今天最高温度是多少",
"今天最低温度是多少",
"现在外面感觉冷吗",
"现在需要穿外套吗",
"现在适合穿短袖吗",
"现在的温度适合外出吗",
"现在的温度适合运动吗",
"现在的温度适合睡觉吗",
"明天会比今天热吗",
"明天会比今天冷吗",
"今天的温度变化大吗",
"现在的温度适合开空调吗",
"现在的温度适合开暖气吗",
"室外的温度是多少",
"室内的温度是多少",
"现在的温度适合种植吗",
"现在的温度适合养宠物吗",
"现在的温度对健康有影响吗",
"现在的温度是否舒适",
"现在的温度是否适合工作"
],
"base64.encode": [
"请将数据使用base64编码%s",
"需要将以下数据base64编码%s",
"请将此字符串转为base64%s",
"将数据转为base64编码%s",
"信息base64编码%s",
"请帮忙编码base64%s",
"将数据编码为base64%s"
],
"base64.decode": [
"请解码这个base64数据%s",
"有base64编码字符串需要解码%s",
"帮忙解码base64%s",
"将base64编码转回原数据%s",
"解码base64信息%s",
"解码这个base64%s",
"将base64转文本%s"
],
"url.encode": [
"请编码这个URL%s",
"URL部分需要编码%s",
"请将URL部分编码%s",
"URL编码转换%s",
"安全传输需编码URL%s",
"编码URL段%s",
"URL数据编码%s"
],
"url.decode": [
"请解码这个URL%s",
"有URL编码需要解码%s",
"解码这个URL%s",
"URL编码转回原URL%s",
"解码URL部分%s",
"解码URL段%s",
"URL编码转文本%s"
],
"html.encode": [
"请编码HTML实体%s",
"文本转为HTML实体%s",
"编码为HTML实体%s",
"文本HTML实体编码%s",
"预防HTML解析编码%s",
"HTML实体编码%s",
"文本HTML使用编码%s"
],
"html.decode": [
"请解码HTML实体%s",
"HTML实体需要解码%s",
"解码HTML实体%s",
"HTML实体转回文本%s",
"HTML实体解码%s",
"解码HTML实体%s",
"HTML实体转文本%s"
],
"None": [
"你好",
"为什么计算机使用二进制"
]
}

7
lib/nlp/extract.ts Normal file
View File

@ -0,0 +1,7 @@
export default function slotExtract(str: string, keywords: string[]) {
let r = str;
for (let keyword of keywords) {
r = r.replace(keyword, "");
}
return r.trim();
}

55
lib/nlp/load.ts Normal file
View File

@ -0,0 +1,55 @@
// @ts-ignore
import { containerBootstrap } from "@nlpjs/core";
// @ts-ignore
import { Nlp } from "@nlpjs/nlp";
// @ts-ignore
import { NluManager, NluNeural } from "@nlpjs/nlu";
// @ts-ignore
import { LangEn } from "@nlpjs/lang-en-min";
// @ts-ignore
import { LangZh } from "@nlpjs/lang-zh";
import * as fflate from 'fflate';
let zh: TrainData = {};
let en: TrainData = {};
type TrainData = {
[key: string]: string[];
};
export class NLU {
manager: any;
inited: boolean = false;
async loadIntentionModel() {
const container = await containerBootstrap();
container.use(Nlp);
container.use(LangEn);
container.use(LangZh);
container.use(NluNeural);
const manager = new NluManager({
container,
locales: ["en", "zh"],
nlu: {
useNoneFeature: true
}
});
const response = await fetch("/model");
const responseBuf = await response.arrayBuffer();
const compressed = new Uint8Array(responseBuf);
const decompressed = fflate.decompressSync(compressed);
const modelText = fflate.strFromU8(decompressed);
manager.fromJSON(JSON.parse(modelText));
this.manager = manager;
// console.log(this.manager);
}
async init() {
await this.loadIntentionModel();
this.inited = true;
return this;
}
async process(lang: string, text: string): Promise<any> {
const actual = await this.manager.process(lang, text);
return actual;
}
}

3
lib/nlp/stopwords.ts Normal file
View File

@ -0,0 +1,3 @@
export const stopwords = ["a","about","above","after","again","against","all","am","an","and","any","are","aren't","as","at","be","because","been","before","being","below","between","both","but","by","can't","cannot","could","couldn't","did","didn't","do","does","doesn't","doing","don't","down","during","each","few","for","from","further","had","hadn't","has","hasn't","have","haven't","having","he","he'd","he'll","he's","her","here","here's","hers","herself","him","himself","his","how","how's","i","i'd","i'll","i'm","i've","if","in","into","is","isn't","it","it's","its","itself","let's","me","more","most","mustn't","my","myself","no","nor","not","of","off","on","once","only","or","other","ought","our","ours","ourselves","out","over","own","please","same","shan't","she","she'd","she'll","she's","should","shouldn't","so","some","such","than","that","that's","the","their","theirs","them","themselves","then","there","there's","these","they","they'd","they'll","they're","they've","this","those","through","to","too","under","until","up","very","was","wasn't","we","we'd","we'll","we're","we've","were","weren't","what","what's","when","when's","where","where's","which","while","who","who's","whom","why","why's","with","won't","would","wouldn't","you","you'd","you'll","you're","you've","your","yours","yourself","yourselves"];
export const convertStopwords = ["transform", "change", "translate", "convert"];

76
lib/nlp/train.ts Normal file
View File

@ -0,0 +1,76 @@
// @ts-ignore
import { containerBootstrap } from "@nlpjs/core";
// @ts-ignore
import { Nlp } from "@nlpjs/nlp";
// @ts-ignore
import { NluManager, NluNeural } from "@nlpjs/nlu";
// @ts-ignore
import { LangEn } from "@nlpjs/lang-en-min";
// @ts-ignore
import { LangZh } from "@nlpjs/lang-zh";
import fs from "node:fs";
import * as fflate from 'fflate';
let zh: TrainData = {};
let en: TrainData = {};
type TrainData = {
[key: string]: string[];
};
export async function trainIntentionModel() {
try {
const dataZH = fs.readFileSync("./lib/nlp/data/zh.json", "utf8");
const dataEN = fs.readFileSync("./lib/nlp/data/en.json", "utf8");
zh = JSON.parse(dataZH);
en = JSON.parse(dataEN);
} catch (err) {
console.error(err);
}
const container = await containerBootstrap();
container.use(Nlp);
container.use(LangEn);
container.use(LangZh);
container.use(NluNeural);
const manager = new NluManager({
container,
locales: ["en", "zh"],
nlu: {
useNoneFeature: true
}
});
// Adds the utterances and intents for the NLP
for (const key in zh) {
for (const value of zh[key]) {
manager.add("zh", value, key);
}
}
for (const key in en) {
for (const value of en[key]) {
manager.add("en", value, key);
}
}
await manager.train();
// let actual = await manager.process("en", "base64 decode bilibili");
// console.log(actual);
// let actualZH = await manager.process("zh", "去除百分号");
// console.log(actualZH);
const resultModel = manager.toJSON();
const buf = fflate.strToU8(JSON.stringify(resultModel));
const gzipped = fflate.gzipSync(buf, {
filename: 'model.json',
mtime: new Date().getTime()
});
fs.writeFileSync("./public/model", Buffer.from(gzipped));
}
trainIntentionModel();

16
lib/normalizeHex.ts Normal file
View File

@ -0,0 +1,16 @@
/**
* A description of the entire function.
*
* @param {string} hexString - The input hexadecimal string to normalize.
* @return {string} The normalized hexadecimal string.
*/
export default function normalizeHex(hexString: string): string {
const chunkSize = 4;
const chunks: string[] = [];
for (let i = 0; i < hexString.length; i += chunkSize) {
chunks.push(hexString.substr(i, chunkSize));
}
return chunks.join(' ');
}

18
lib/normalizeURL.ts Normal file
View File

@ -0,0 +1,18 @@
export function normalizeURL(input: string): string {
try {
// try to create a URL object
const url = new URL(input);
// if the URL is valid, return it
return url.href;
} catch (error) {
// if the URL is invalid, try to add the protocol
const withHTTP = "http://" + input;
try {
const urlWithHTTP = new URL(withHTTP);
return urlWithHTTP.href;
} catch (error) {
// if the URL is still invalid, return the original input
return input;
}
}
}

View File

@ -0,0 +1,17 @@
export class NLPResult {
constructor(
public suggestion: string | null = null,
public intention: string | null = null,
public probability: number = 0,
public confidence: number = 0,
public relevanceBase: number = 2000,
public confidenceWeight: number = 0.2,
public type: string = "text",
public prompt?: string | React.ReactElement
) {
}
get relevance(): number {
return this.relevanceBase * this.probability + this.confidence * this.relevanceBase * this.confidenceWeight;
}
}

View File

@ -0,0 +1,90 @@
import slotExtract from "../nlp/extract";
import removeStopwords from "../nlp/stopwords";
import { NLPResult } from "./NLPResult";
import { Kbd } from "@nextui-org/react";
interface KeywordsDict {
[key: string]: number;
}
interface IntentionsDict {
[key: string]: number;
}
export function validBase64(str: string) {
return str.length % 4 == 0 && /^[A-Za-z0-9+/]+[=]{0,2}$/.test(str);
}
export function base64NLP(str: string) {
const keywords: KeywordsDict = {
base64: 1,
b64: 0.95,
base: 0.5
};
let result = new NLPResult(null, null, 0.0, 0.0);
for (let keyword of Object.keys(keywords)) {
const pos = str.trim().indexOf(keyword);
const l = str.length;
const w = str.split(" ").length;
if (w > 1 && (pos === 0 || pos == l)) {
result.probability += keywords[keyword];
break;
}
}
const intentions: IntentionsDict = {
decode: 0.1,
encode: 1
};
for (let intention of Object.keys(intentions)) {
const pos = str.trim().indexOf(intention);
const w = str.split(" ").length;
if (w > 1 && pos !== -1) {
result.confidence += intentions[intention];
result.intention = `base64.${intention}`;
break;
}
}
let processedQuery = str;
if (result.intention === "base64.encode") {
const blacklist = Object.keys(keywords).concat(Object.keys(intentions)).concat(["convert", "turn"]);
processedQuery = slotExtract(str, blacklist);
} else if (result.intention === "base64.decode") {
processedQuery = removeStopwords(str, Object.keys(keywords).concat(Object.keys(intentions))).trim();
}
if (result.intention === "base64.decode") {
if (validBase64(processedQuery)) {
result.confidence = 1;
} else {
result.confidence = 0;
}
} else if (validBase64(processedQuery) && result.intention !== "base64.encode") {
result.intention = "base64.decode";
result.confidence += Math.max(1 / Math.log2(1 / processedQuery.length) + 1, 0);
result.probability += Math.max(1 / Math.log2(1 / processedQuery.length) + 1, 0);
}
switch (result.intention) {
case "base64.encode":
result.suggestion = btoa(processedQuery);
result.prompt = (
<span>
Base64 Encode (Hit <Kbd keys={["enter"]}></Kbd> to copy):
</span>
);
break;
case "base64.decode":
if (result.confidence > 0.1) result.suggestion = atob(processedQuery);
result.prompt = (
<span>
Base64 Decode (Hit <Kbd keys={["enter"]}></Kbd> to copy):
</span>
);
break;
default:
break;
}
return result;
}

4
lib/search.ts Normal file
View File

@ -0,0 +1,4 @@
export default function(query: string, engine: string, newTab: boolean = true) {
if(newTab) window.open(engine.replace("%s", query));
else window.location.href = engine.replace("%s", query);
}

View File

@ -0,0 +1,19 @@
import { CLIENT_VERSION } from "../version";
export function sendError(error: Error) {
fetch("/api/error", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
message: error.message,
name: error.name,
time: new Date().getTime()/1000,
version: CLIENT_VERSION,
ua: navigator.userAgent,
cause: error.cause,
stack: error.stack
})
})
}

1
lib/url/tldList.ts Normal file

File diff suppressed because one or more lines are too long

33
lib/url/validLink.ts Normal file
View File

@ -0,0 +1,33 @@
import punycode from "punycode/";
import { tldList } from "./tldList";
export default function validLink(link: string) {
let finalURL = '';
try {
const url = new URL(link);
finalURL = url.origin;
return true;
} catch (error) {
// if the URL is invalid, try to add the protocol
try {
const urlWithHTTP = new URL("http://" + link);
finalURL = urlWithHTTP.origin;
} catch (error) {
return false;
}
}
if (validTLD(finalURL)) {
return true;
} else {
return false;
}
}
export function validTLD(domain: string): boolean {
const tld = punycode.toUnicode(domain.split(".").reverse()[0]);
if (tldList.includes(tld)) {
return true;
} else {
return false;
}
}

3
lib/version.ts Normal file
View File

@ -0,0 +1,3 @@
export const SPARKHOME_VERSION="4.17.0";
export const CLIENT_VERSION="4.17.0";
export const NEXT_API_VERSION="4.14.3";

View File

@ -0,0 +1,39 @@
export function getClosestHourTimestamp(): string {
const now = new Date();
now.setMinutes(0, 0, 0); // 设置分钟、秒和毫秒为0
// 获取本地时间的年份、月份、日期、小时
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0'); // 月份从0开始
const day = String(now.getDate()).padStart(2, '0');
const hour = String(now.getHours()).padStart(2, '0');
// 拼接成所需的格式
const localHourTimestamp = `${year}-${month}-${day}T${hour}:00`;
return localHourTimestamp;
}
export function findClosestDateIndex(dates: string[], utc_offset_seconds: number): number {
const now = new Date();
const nowTimestamp = now.getTime();
const offsetMilliseconds = utc_offset_seconds * 1000;
let closestIndex = -1;
let closestDiff = Infinity;
for (let i = 0; i < dates.length; i++) {
const date = new Date(dates[i]);
const adjustedTimestamp = date.getTime();
if (adjustedTimestamp <= nowTimestamp) {
const diff = nowTimestamp - adjustedTimestamp;
if (diff < closestDiff) {
closestDiff = diff;
closestIndex = i;
}
}
}
return closestIndex;
}

View File

@ -0,0 +1,17 @@
const options = {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 3600
};
export function getLocationNative(callback: Function) {
navigator.geolocation.getCurrentPosition(
(pos: GeolocationPosition) => {
callback(pos.coords);
},
(err: GeolocationPositionError) => {
callback(err);
},
options
);
}

23
lib/weather/getWeather.ts Normal file
View File

@ -0,0 +1,23 @@
export async function getWeather(lat: number, lon: number) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const cacheKey = `weather-cache-${lat.toFixed(2)}-${lon.toFixed(2)}-${timezone}`;
const localData = localStorage.getItem(cacheKey);
if (localData != null) {
console.log('Using cache');
const parsedLocalData = JSON.parse(localData);
if (parsedLocalData["hourly"]["time"][0] != undefined &&
new Date().getTime() - new Date(parsedLocalData["hourly"]["time"][0]).getTime() < 86400 * 1000
) {
return parsedLocalData;
}
else {
console.log('Cache expired');
localStorage.removeItem(cacheKey);
}
}
const url = `https://api.open-meteo.com/v1/cma?latitude=${lat.toString()}&longitude=${lon.toString()}&hourly=apparent_temperature,precipitation,weather_code&timezone=${encodeURIComponent(timezone)}&forecast_days=1`;
const response = await fetch(url);
const responseJson = await response.json();
localStorage.setItem(cacheKey, JSON.stringify(responseJson));
return responseJson;
}

294
lib/weather/wmocode.ts Normal file
View File

@ -0,0 +1,294 @@
type WeatherInfo = {
description: string;
image: string;
};
type WMOCodeTable = {
[key: string]: {
day: WeatherInfo;
night: WeatherInfo;
};
};
export let WMOCodeTable: WMOCodeTable = {
"0": {
day: {
description: "Sunny",
image: "http://openweathermap.org/img/wn/01d@2x.png"
},
night: {
description: "Clear",
image: "http://openweathermap.org/img/wn/01n@2x.png"
}
},
"1": {
day: {
description: "Mainly Sunny",
image: "http://openweathermap.org/img/wn/01d@2x.png"
},
night: {
description: "Mainly Clear",
image: "http://openweathermap.org/img/wn/01n@2x.png"
}
},
"2": {
day: {
description: "Partly Cloudy",
image: "http://openweathermap.org/img/wn/02d@2x.png"
},
night: {
description: "Partly Cloudy",
image: "http://openweathermap.org/img/wn/02n@2x.png"
}
},
"3": {
day: {
description: "Cloudy",
image: "http://openweathermap.org/img/wn/03d@2x.png"
},
night: {
description: "Cloudy",
image: "http://openweathermap.org/img/wn/03n@2x.png"
}
},
"45": {
day: {
description: "Foggy",
image: "http://openweathermap.org/img/wn/50d@2x.png"
},
night: {
description: "Foggy",
image: "http://openweathermap.org/img/wn/50n@2x.png"
}
},
"48": {
day: {
description: "Rime Fog",
image: "http://openweathermap.org/img/wn/50d@2x.png"
},
night: {
description: "Rime Fog",
image: "http://openweathermap.org/img/wn/50n@2x.png"
}
},
"51": {
day: {
description: "Light Drizzle",
image: "http://openweathermap.org/img/wn/09d@2x.png"
},
night: {
description: "Light Drizzle",
image: "http://openweathermap.org/img/wn/09n@2x.png"
}
},
"53": {
day: {
description: "Drizzle",
image: "http://openweathermap.org/img/wn/09d@2x.png"
},
night: {
description: "Drizzle",
image: "http://openweathermap.org/img/wn/09n@2x.png"
}
},
"55": {
day: {
description: "Heavy Drizzle",
image: "http://openweathermap.org/img/wn/09d@2x.png"
},
night: {
description: "Heavy Drizzle",
image: "http://openweathermap.org/img/wn/09n@2x.png"
}
},
"56": {
day: {
description: "Light Freezing Drizzle",
image: "http://openweathermap.org/img/wn/09d@2x.png"
},
night: {
description: "Light Freezing Drizzle",
image: "http://openweathermap.org/img/wn/09n@2x.png"
}
},
"57": {
day: {
description: "Freezing Drizzle",
image: "http://openweathermap.org/img/wn/09d@2x.png"
},
night: {
description: "Freezing Drizzle",
image: "http://openweathermap.org/img/wn/09n@2x.png"
}
},
"61": {
day: {
description: "Light Rain",
image: "http://openweathermap.org/img/wn/10d@2x.png"
},
night: {
description: "Light Rain",
image: "http://openweathermap.org/img/wn/10n@2x.png"
}
},
"63": {
day: {
description: "Rain",
image: "http://openweathermap.org/img/wn/10d@2x.png"
},
night: {
description: "Rain",
image: "http://openweathermap.org/img/wn/10n@2x.png"
}
},
"65": {
day: {
description: "Heavy Rain",
image: "http://openweathermap.org/img/wn/10d@2x.png"
},
night: {
description: "Heavy Rain",
image: "http://openweathermap.org/img/wn/10n@2x.png"
}
},
"66": {
day: {
description: "Light Freezing Rain",
image: "http://openweathermap.org/img/wn/10d@2x.png"
},
night: {
description: "Light Freezing Rain",
image: "http://openweathermap.org/img/wn/10n@2x.png"
}
},
"67": {
day: {
description: "Freezing Rain",
image: "http://openweathermap.org/img/wn/10d@2x.png"
},
night: {
description: "Freezing Rain",
image: "http://openweathermap.org/img/wn/10n@2x.png"
}
},
"71": {
day: {
description: "Light Snow",
image: "http://openweathermap.org/img/wn/13d@2x.png"
},
night: {
description: "Light Snow",
image: "http://openweathermap.org/img/wn/13n@2x.png"
}
},
"73": {
day: {
description: "Snow",
image: "http://openweathermap.org/img/wn/13d@2x.png"
},
night: {
description: "Snow",
image: "http://openweathermap.org/img/wn/13n@2x.png"
}
},
"75": {
day: {
description: "Heavy Snow",
image: "http://openweathermap.org/img/wn/13d@2x.png"
},
night: {
description: "Heavy Snow",
image: "http://openweathermap.org/img/wn/13n@2x.png"
}
},
"77": {
day: {
description: "Snow Grains",
image: "http://openweathermap.org/img/wn/13d@2x.png"
},
night: {
description: "Snow Grains",
image: "http://openweathermap.org/img/wn/13n@2x.png"
}
},
"80": {
day: {
description: "Light Showers",
image: "http://openweathermap.org/img/wn/09d@2x.png"
},
night: {
description: "Light Showers",
image: "http://openweathermap.org/img/wn/09n@2x.png"
}
},
"81": {
day: {
description: "Showers",
image: "http://openweathermap.org/img/wn/09d@2x.png"
},
night: {
description: "Showers",
image: "http://openweathermap.org/img/wn/09n@2x.png"
}
},
"82": {
day: {
description: "Heavy Showers",
image: "http://openweathermap.org/img/wn/09d@2x.png"
},
night: {
description: "Heavy Showers",
image: "http://openweathermap.org/img/wn/09n@2x.png"
}
},
"85": {
day: {
description: "Light Snow Showers",
image: "http://openweathermap.org/img/wn/13d@2x.png"
},
night: {
description: "Light Snow Showers",
image: "http://openweathermap.org/img/wn/13n@2x.png"
}
},
"86": {
day: {
description: "Snow Showers",
image: "http://openweathermap.org/img/wn/13d@2x.png"
},
night: {
description: "Snow Showers",
image: "http://openweathermap.org/img/wn/13n@2x.png"
}
},
"95": {
day: {
description: "Thunderstorm",
image: "http://openweathermap.org/img/wn/11d@2x.png"
},
night: {
description: "Thunderstorm",
image: "http://openweathermap.org/img/wn/11n@2x.png"
}
},
"96": {
day: {
description: "Light Thunderstorms With Hail",
image: "http://openweathermap.org/img/wn/11d@2x.png"
},
night: {
description: "Light Thunderstorms With Hail",
image: "http://openweathermap.org/img/wn/11n@2x.png"
}
},
"99": {
day: {
description: "Thunderstorm With Hail",
image: "http://openweathermap.org/img/wn/11d@2x.png"
},
night: {
description: "Thunderstorm With Hail",
image: "http://openweathermap.org/img/wn/11n@2x.png"
}
}
};

27
messages/en-US.json Normal file
View File

@ -0,0 +1,27 @@
{
"Search": {
"placeholder": "Search or type a URL",
"engine-aria": "Switch search engine",
"engine": {
"google": "Google",
"baidu": "Baidu",
"bing": "Bing",
"duckduckgo": "DuckDuckGo",
"yandex": "Yandex",
"yahoo": "Yahoo",
"ecosia": "Ecosia"
},
"search-help-text": "Search {engine}"
},
"404": {
"title": "Page Not Found"
},
"About": {
"title": "SparkHome"
},
"tools": {
"base64": {
"title": "Base64 tools - LuminaraUtils"
}
}
}

24
messages/zh-CN.json Normal file
View File

@ -0,0 +1,24 @@
{
"Search": {
"placeholder": "搜索或输入网址",
"engine-aria": "搜索引擎切换",
"engine": {
"google": "谷歌",
"baidu": "百度",
"bing": "必应",
"duckduckgo": "DuckDuckGo",
"yandex": "Yandex",
"yahoo": "雅虎",
"ecosia": "Ecosia"
},
"search-help-text": "{engine} 搜索"
},
"404": {
"title": "未找到"
},
"tools": {
"base64": {
"title": "Base64 工具"
}
}
}

14
middleware.ts Normal file
View File

@ -0,0 +1,14 @@
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
// A list of all locales that are supported
locales: ['en-US', 'zh-CN'],
// Used when no locale matches
defaultLocale: 'en-US'
});
export const config = {
// Match only internationalized pathnames
matcher: ['/', '/(zh-CN|en-US)/:path*']
};

View File

@ -1,4 +1,9 @@
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
@ -9,7 +14,8 @@ const nextConfig = {
pathname: "/*"
}
]
}
},
output: 'standalone'
};
export default nextConfig;
export default withNextIntl(nextConfig);

View File

@ -1,29 +1,64 @@
{
"name": "sparkhome",
"version": "4.1.0",
"version": "4.17.0",
"private": false,
"scripts": {
"dev": "next dev",
"build": "next build",
"build:NLP": "tsx ./lib/nlp/train.ts",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "jest",
"docs:dev": "vitepress dev doc",
"docs:build": "vitepress build doc",
"docs:preview": "vitepress preview doc"
},
"dependencies": {
"next": "14.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@nextui-org/react": "^2.3.6",
"@nlpjs/basic": "^4.27.0",
"@nlpjs/builtin-compromise": "^4.26.1",
"@nlpjs/core": "^4.26.1",
"@nlpjs/lang-en-min": "^4.26.1",
"@nlpjs/lang-zh": "^4.26.1",
"@nlpjs/nlp": "^4.27.0",
"clsx": "^2.1.1",
"fflate": "^0.8.2",
"framer-motion": "^11.1.7",
"next": "14.1.4",
"next-intl": "^3.12.0",
"next-themes": "^0.3.0",
"openmeteo": "^1.1.4",
"pino": "^9.0.0",
"punycode": "^2.3.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"recoil": "^0.7.7",
"search-engine-autocomplete": "^0.4.3",
"tailwind-merge": "^2.3.0",
"unicode-encode": "^1.4.2",
"valid-url": "^1.0.9",
"validate-color": "^2.2.4"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@iconify-icon/react": "^2.1.0",
"@jest/globals": "^29.7.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.3.1",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.7",
"@types/punycode": "^2.1.4",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/valid-url": "^1.0.7",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
"autoprefixer": "^10.4.19",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"ts-jest": "^29.1.2",
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"vitepress": "^1.2.3",
"vue": "^3.4.29"
}
}

File diff suppressed because it is too large Load Diff

BIN
public/model Normal file

Binary file not shown.

View File

@ -1,20 +1,21 @@
import type { Config } from "tailwindcss";
import { nextui } from "@nextui-org/react";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}"
],
theme: {
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
"gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))"
}
}
},
},
},
plugins: [],
plugins: [nextui()]
};
export default config;

View File

@ -0,0 +1,18 @@
import { NLP } from "@/lib/nlp/base";
import { convertStopwords } from "@/lib/nlp/stopwords";
import { describe, expect, test } from "@jest/globals";
describe("Test 1", () => {
test("basic", () => {
const nlp = new NLP("please", "remove-stopword");
nlp.removeStopwords();
expect(nlp.query).toBe("");
});
test("convert something", () => {
const nlp = new NLP("please convert 1cm to m", "remove-stopword");
nlp.removeStopwords(convertStopwords);
nlp.trim();
expect(nlp.query).toBe("1cm m");
});
});

11
test/base64.test.ts Normal file
View File

@ -0,0 +1,11 @@
import { base64NLP } from "@/lib/onesearch/baseCheck";
import { describe, expect, test } from "@jest/globals";
describe("To auto-detect the intention of decoding an base64 string", () => {
test("Implicit declaration", () => {
expect(base64NLP("base64 encode encode MjM6MjQgQXByIDI1LCAyMDI0").intention).toBe("base64.encode");
expect(base64NLP("base64 encode encode MjM6MjQgQXByIDI1LCAyMDI0").suggestion).toBe(
"ZW5jb2RlIE1qTTZNalFnUVhCeUlESTFMQ0F5TURJMA=="
);
});
});

64
test/validLink.test.ts Normal file
View File

@ -0,0 +1,64 @@
import { describe, expect, test } from "@jest/globals";
import validLink, { validTLD } from "../lib/url/validLink";
describe("Check if a string is an accessible domain/URL", () => {
test("Plain, full URL", () => {
// Plain form
expect(validLink("http://example.com")).toBe(true);
// With https and path
expect(validLink("https://jestjs.io/docs/getting-started/")).toBe(true);
// With anchor
expect(validLink("https://difftastic.wilfred.me.uk/zh-CN/git.html#git-difftool")).toBe(true);
// With params
expect(validLink("https://www.bilibili.com/list/ml2252204359?oid=990610203&bvid=BV1sx4y1g7Hh")).toBe(true);
});
test("Punycode URL", () => {
expect(validLink("https://原神大学.com/")).toBe(true);
expect(validLink("中国原神大学.com")).toBe(true);
});
test("Invalid TLD with protocol", () => {
expect(validLink("https://www.example.notexist")).toBe(true);
});
test("Invalid TLD with no protocol", () => {
expect(validLink("www.example.notexist")).toBe(false);
});
});
// Reference: https://www.iana.org/domains/root/db
describe("Check if the given TLD exist and assigned.", () => {
test("Valid normal TLD", () => {
expect(validTLD("com")).toBe(true);
expect(validTLD("top")).toBe(true);
expect(validTLD("net")).toBe(true);
expect(validTLD("org")).toBe(true);
});
test("Valid new TLDs", () => {
// they really exist!
expect(validTLD("foo")).toBe(true);
expect(validTLD("bar")).toBe(true);
});
test("Exist but not assigned TLD", () => {
expect(validTLD("active")).toBe(false);
expect(validTLD("off")).toBe(false);
});
test("with dot", () => {
expect(validTLD(".com")).toBe(true);
expect(validTLD(".us")).toBe(true);
expect(validTLD(".cn")).toBe(true);
expect(validTLD(".io")).toBe(true);
});
test("Punycode TLDs", () => {
expect(validTLD(".中国")).toBe(true);
expect(validTLD(".РФ")).toBe(true);
expect(validTLD(".कॉम")).toBe(true);
expect(validTLD("ایران")).toBe(true);
expect(validTLD("இலங்கை")).toBe(true);
expect(validTLD("გე")).toBe(true);
expect(validTLD("ポイント")).toBe(true);
});
test("Punycode TLDs but not assigned", () => {
expect(validTLD("テスト")).toBe(false);
expect(validTLD("परीक्षा")).toBe(false);
expect(validTLD("测试")).toBe(false);
});
});