Merge branch 'dev' into translation
This commit is contained in:
commit
53316d73e5
4 changed files with 299 additions and 5 deletions
|
|
@ -5,13 +5,15 @@
|
|||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Button } from "@components/Button";
|
||||
import { SettingsSection } from "@components/settings/tabs/plugins/components/Common";
|
||||
import { Switch } from "@components/Switch";
|
||||
import { useForceUpdater } from "@utils/react";
|
||||
import { OptionType } from "@utils/types";
|
||||
import { Select, TextInput } from "@webpack/common";
|
||||
import { React, Select, showToast, TextArea, TextInput, Toasts } from "@webpack/common";
|
||||
|
||||
import { ServiceType } from "./types";
|
||||
import { parseShareXConfig } from "./utils/sharex";
|
||||
|
||||
const serviceOptions = [
|
||||
{ label: "Zipline", value: ServiceType.ZIPLINE, default: true },
|
||||
|
|
@ -20,7 +22,8 @@ const serviceOptions = [
|
|||
{ label: "S3-Compatible", value: ServiceType.S3 },
|
||||
{ label: "Catbox.moe", value: ServiceType.CATBOX },
|
||||
...(IS_DISCORD_DESKTOP ? [{ label: "0x0.st", value: ServiceType.ZEROX0 }] : []),
|
||||
{ label: "Litterbox", value: ServiceType.LITTERBOX }
|
||||
{ label: "Litterbox", value: ServiceType.LITTERBOX },
|
||||
{ label: "ShareX Custom Uploader", value: ServiceType.SHAREX }
|
||||
];
|
||||
|
||||
const litterboxOptions = [
|
||||
|
|
@ -134,6 +137,12 @@ export const settings = definePluginSettings({
|
|||
default: "",
|
||||
hidden: true
|
||||
},
|
||||
sharexConfig: {
|
||||
type: OptionType.STRING,
|
||||
description: "ShareX custom uploader JSON",
|
||||
default: "",
|
||||
hidden: true
|
||||
},
|
||||
stripQueryParams: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Strip query params from uploaded URLs",
|
||||
|
|
@ -182,12 +191,49 @@ function SettingTextInput(props: {
|
|||
export function SettingsComponent() {
|
||||
const update = useForceUpdater();
|
||||
const { store } = settings;
|
||||
const sharexFileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const isNest = store.serviceType === ServiceType.NEST;
|
||||
const isEzHost = store.serviceType === ServiceType.EZHOST;
|
||||
const isS3 = store.serviceType === ServiceType.S3;
|
||||
const isZipline = store.serviceType === ServiceType.ZIPLINE;
|
||||
const isCatbox = store.serviceType === ServiceType.CATBOX;
|
||||
const isLitterbox = store.serviceType === ServiceType.LITTERBOX;
|
||||
const isShareX = store.serviceType === ServiceType.SHAREX;
|
||||
|
||||
const validateShareXConfig = () => {
|
||||
try {
|
||||
parseShareXConfig(store.sharexConfig || "");
|
||||
showToast("ShareX config is valid", Toasts.Type.SUCCESS);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid ShareX config";
|
||||
showToast(message, Toasts.Type.FAILURE);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerShareXFileUpload = () => {
|
||||
sharexFileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleShareXFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||
try {
|
||||
const content = String(e.target?.result || "");
|
||||
const parsed = parseShareXConfig(content);
|
||||
store.sharexConfig = JSON.stringify(parsed, null, 2);
|
||||
update();
|
||||
showToast("Imported ShareX config", Toasts.Type.SUCCESS);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to import ShareX config";
|
||||
showToast(message, Toasts.Type.FAILURE);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -342,6 +388,35 @@ export function SettingsComponent() {
|
|||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{isShareX && (
|
||||
<>
|
||||
<SettingsSection
|
||||
name="ShareX Custom Uploader Config"
|
||||
description="Paste your ShareX custom uploader JSON (.sxcu/.json). DestinationType must include FileUploader or ImageUploader."
|
||||
>
|
||||
<TextArea
|
||||
value={store.sharexConfig}
|
||||
rows={10}
|
||||
placeholder='{"RequestMethod":"POST","RequestURL":"https://example.com/api/upload","Body":"MultipartFormData"}'
|
||||
onChange={v => store.sharexConfig = v}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSection name="ShareX Config Actions" description="Import from file or validate pasted config">
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<Button size="small" onClick={triggerShareXFileUpload}>Import .sxcu/.json</Button>
|
||||
<Button size="small" onClick={validateShareXConfig}>Validate</Button>
|
||||
</div>
|
||||
<input
|
||||
ref={sharexFileInputRef}
|
||||
type="file"
|
||||
accept=".sxcu,.json,application/json,text/plain"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleShareXFileUpload}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsSection tag="label" name="Strip Query Parameters" description="Strip query parameters from the uploaded file URL" inlineSetting>
|
||||
<Switch
|
||||
checked={store.stripQueryParams}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ export enum ServiceType {
|
|||
S3 = "s3",
|
||||
CATBOX = "catbox",
|
||||
ZEROX0 = "0x0",
|
||||
LITTERBOX = "litterbox"
|
||||
LITTERBOX = "litterbox",
|
||||
SHAREX = "sharex"
|
||||
}
|
||||
|
||||
export const serviceLabels: Record<ServiceType, string> = {
|
||||
|
|
@ -21,7 +22,8 @@ export const serviceLabels: Record<ServiceType, string> = {
|
|||
[ServiceType.S3]: "S3-Compatible",
|
||||
[ServiceType.CATBOX]: "Catbox",
|
||||
[ServiceType.ZEROX0]: "0x0.st",
|
||||
[ServiceType.LITTERBOX]: "Litterbox"
|
||||
[ServiceType.LITTERBOX]: "Litterbox",
|
||||
[ServiceType.SHAREX]: "ShareX Custom Uploader"
|
||||
};
|
||||
|
||||
export interface UploadResponse {
|
||||
|
|
@ -41,3 +43,17 @@ export interface NativeUploadResult {
|
|||
url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ShareXUploaderConfig {
|
||||
Version?: string;
|
||||
Name?: string;
|
||||
DestinationType?: string;
|
||||
RequestMethod?: string;
|
||||
RequestURL?: string;
|
||||
Headers?: Record<string, string | number | boolean>;
|
||||
Body?: string;
|
||||
FileFormName?: string;
|
||||
Arguments?: Record<string, string | number | boolean>;
|
||||
URL?: string;
|
||||
ErrorMessage?: string;
|
||||
}
|
||||
|
|
|
|||
92
src/equicordplugins/fileUpload/utils/sharex.ts
Normal file
92
src/equicordplugins/fileUpload/utils/sharex.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ShareXUploaderConfig } from "../types";
|
||||
|
||||
const JSON_TEMPLATE_REGEX = /\$json:([^$]+)\$|\{json:([^}]+)\}/g;
|
||||
const RESPONSE_TEMPLATE_REGEX = /\$response\$|\{response\}/g;
|
||||
|
||||
function parseDestinationTypes(destinationType?: string): string[] {
|
||||
if (!destinationType) return [];
|
||||
|
||||
return destinationType
|
||||
.split(",")
|
||||
.map(type => type.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getNestedValue(data: unknown, path: string): unknown {
|
||||
const normalizedPath = path.replace(/\[(\d+)\]/g, ".$1");
|
||||
const segments = normalizedPath.split(".").filter(Boolean);
|
||||
|
||||
let current: unknown = data;
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== "object" || !(segment in current)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function parseShareXConfig(configText: string): ShareXUploaderConfig {
|
||||
let parsed: unknown;
|
||||
const normalizedConfigText = configText.replace(/^\uFEFF/, "").trim();
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(normalizedConfigText);
|
||||
} catch {
|
||||
throw new Error("Invalid ShareX JSON");
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("ShareX config must be a JSON object");
|
||||
}
|
||||
|
||||
const config = parsed as ShareXUploaderConfig;
|
||||
|
||||
if (!config.RequestURL || typeof config.RequestURL !== "string") {
|
||||
throw new Error("ShareX RequestURL is required");
|
||||
}
|
||||
|
||||
if (!isSupportedShareXDestination(config)) {
|
||||
throw new Error("ShareX DestinationType must include FileUploader or ImageUploader");
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function isSupportedShareXDestination(config: ShareXUploaderConfig): boolean {
|
||||
const destinationTypes = parseDestinationTypes(config.DestinationType);
|
||||
|
||||
if (destinationTypes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return destinationTypes.includes("FileUploader") || destinationTypes.includes("ImageUploader");
|
||||
}
|
||||
|
||||
export function resolveShareXTemplate(
|
||||
template: string | undefined,
|
||||
responseText: string,
|
||||
responseJson: unknown
|
||||
): string | undefined {
|
||||
if (!template) return undefined;
|
||||
|
||||
let output = template.trim();
|
||||
if (!output) return undefined;
|
||||
|
||||
output = output.replace(RESPONSE_TEMPLATE_REGEX, responseText);
|
||||
output = output.replace(JSON_TEMPLATE_REGEX, (_, pathA: string, pathB: string) => {
|
||||
const path = pathA || pathB;
|
||||
const value = getNestedValue(responseJson, path);
|
||||
return value == null ? "" : String(value);
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { settings } from "@equicordplugins/fileUpload/settings";
|
||||
import { ServiceType, UploadResponse } from "@equicordplugins/fileUpload/types";
|
||||
import { ServiceType, ShareXUploaderConfig, UploadResponse } from "@equicordplugins/fileUpload/types";
|
||||
import { copyToClipboard } from "@utils/clipboard";
|
||||
import { PluginNative } from "@utils/types";
|
||||
import { showToast, Toasts } from "@webpack/common";
|
||||
|
|
@ -13,6 +13,7 @@ import { showToast, Toasts } from "@webpack/common";
|
|||
import { convertApngToGif } from "./apngToGif";
|
||||
import { getExtensionFromBytes, getExtensionFromMime, getMimeFromExtension, getUrlExtension } from "./getMediaUrl";
|
||||
import { isS3Configured, uploadToS3 } from "./s3";
|
||||
import { parseShareXConfig, resolveShareXTemplate } from "./sharex";
|
||||
|
||||
const Native = IS_DISCORD_DESKTOP
|
||||
? VencordNative.pluginHelpers.FileUpload as PluginNative<typeof import("../native")>
|
||||
|
|
@ -22,6 +23,106 @@ const CORS_PROXY = "https://cors.keiran0.workers.dev"; // im hosting this on clo
|
|||
|
||||
let isUploading = false;
|
||||
|
||||
function resolveShareXRequestValue(value: string | number | boolean, filename: string): string {
|
||||
return String(value)
|
||||
.replace(/\$filename\$/g, filename)
|
||||
.replace(/\{filename\}/g, filename);
|
||||
}
|
||||
|
||||
function parseShareXConfigFromSettings(): ShareXUploaderConfig {
|
||||
const configText = settings.store.sharexConfig || "";
|
||||
if (!configText.trim()) {
|
||||
throw new Error("ShareX config is required");
|
||||
}
|
||||
|
||||
return parseShareXConfig(configText);
|
||||
}
|
||||
|
||||
async function uploadToShareX(fileBlob: Blob, filename: string): Promise<string> {
|
||||
const config = parseShareXConfigFromSettings();
|
||||
const method = (config.RequestMethod || "POST").toUpperCase();
|
||||
const requestUrl = config.RequestURL!.trim();
|
||||
const bodyType = (config.Body || "MultipartFormData").toLowerCase();
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(config.Headers || {})) {
|
||||
headers.set(key, resolveShareXRequestValue(value, filename));
|
||||
}
|
||||
|
||||
const buildArguments = () => {
|
||||
const args: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(config.Arguments || {})) {
|
||||
args[key] = resolveShareXRequestValue(value, filename);
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
let body: BodyInit;
|
||||
|
||||
if (bodyType === "multipartformdata" || bodyType === "formdata") {
|
||||
headers.delete("content-type");
|
||||
|
||||
const formData = new FormData();
|
||||
const fileField = config.FileFormName || "file";
|
||||
formData.append(fileField, fileBlob, filename);
|
||||
|
||||
const args = buildArguments();
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
|
||||
body = formData;
|
||||
} else if (bodyType === "binary") {
|
||||
body = fileBlob;
|
||||
} else if (bodyType === "json") {
|
||||
if (!headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
|
||||
const payload = buildArguments();
|
||||
body = JSON.stringify(payload);
|
||||
} else {
|
||||
throw new Error(`Unsupported ShareX Body type: ${config.Body || "unknown"}`);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(requestUrl, { method, headers, body });
|
||||
} catch (error) {
|
||||
if (Native) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const proxiedUrl = `${CORS_PROXY}?url=${encodeURIComponent(requestUrl)}`;
|
||||
response = await fetch(proxiedUrl, { method, headers, body });
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
let responseJson: unknown = null;
|
||||
try {
|
||||
responseJson = responseText ? JSON.parse(responseText) : null;
|
||||
} catch {
|
||||
responseJson = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const configuredError = resolveShareXTemplate(config.ErrorMessage, responseText, responseJson);
|
||||
throw new Error(configuredError || `Upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const configuredUrl = resolveShareXTemplate(config.URL, responseText, responseJson)?.trim();
|
||||
const fallbackUrl = typeof responseJson === "object" && responseJson && "url" in responseJson
|
||||
? String((responseJson as Record<string, unknown>).url || "")
|
||||
: responseText.trim();
|
||||
|
||||
const resultUrl = configuredUrl || fallbackUrl;
|
||||
if (!resultUrl) {
|
||||
throw new Error("No URL returned from ShareX uploader");
|
||||
}
|
||||
|
||||
return resultUrl;
|
||||
}
|
||||
|
||||
async function uploadToZipline(fileBlob: Blob, filename: string): Promise<string> {
|
||||
const { serviceUrl, ziplineToken, folderId } = settings.store;
|
||||
|
||||
|
|
@ -141,6 +242,13 @@ export function isConfigured(): boolean {
|
|||
return Boolean(Native);
|
||||
case ServiceType.LITTERBOX:
|
||||
return true;
|
||||
case ServiceType.SHAREX:
|
||||
try {
|
||||
parseShareXConfigFromSettings();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
case ServiceType.ZIPLINE:
|
||||
default:
|
||||
return Boolean(serviceUrl && ziplineToken);
|
||||
|
|
@ -348,6 +456,9 @@ export async function uploadFile(url: string): Promise<void> {
|
|||
case ServiceType.LITTERBOX:
|
||||
uploadedUrl = await uploadToLitterbox(typedBlob, filename);
|
||||
break;
|
||||
case ServiceType.SHAREX:
|
||||
uploadedUrl = await uploadToShareX(typedBlob, filename);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unknown service type");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue