Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 178 additions & 133 deletions SOURCE_MANIFEST.json

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions apps/web/src/api/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { stageFile, type StagedFile } from "./files";
import { setLocale } from "../i18n/locale";
import { UploadRefused } from "./uploadTypes";

type Progress = { phase: "uploading" | "processing"; loaded: number; total: number | null };
type Options = { signal?: AbortSignal; onProgress?: (progress: Progress) => void };
Expand All @@ -18,6 +19,8 @@ class FakeXHR {
ontimeout: null | (() => void) = null;
status = 0;
responseText = "";
responseHeaders: Record<string, string> = {};
getResponseHeader = (name: string) => this.responseHeaders[name.toLowerCase()] ?? null;
timeout = 0;
body: FormData | null = null;
open = vi.fn();
Expand Down Expand Up @@ -163,6 +166,28 @@ it.each([[413, '{"detail":"server detail"}', "server detail"], [503, "not json",
},
);

it("turns a refusal the server names by code into one that names the file, and keeps the old sentence otherwise", async () => {
const refused = start();
FakeXHR.instances[0].responseHeaders = { "x-thoth-upload-error": "UPLOAD_UNSUPPORTED_TYPE" };
FakeXHR.instances[0].complete(415, JSON.stringify({ detail: "unsupported research file type" }));
await refused.done;
expect(refused.result.error).toBeInstanceOf(UploadRefused);
expect(refused.result.error).toMatchObject({ code: "UPLOAD_UNSUPPORTED_TYPE", filename: "a.md" });
expect(refused.result.error?.message).toContain('"a.md" is not a file type that can be uploaded.');
setLocale("ko");
expect(refused.result.error?.message).toContain('"a.md" 파일은 올릴 수 없는 형식입니다.');
setLocale("en");
// A code this screen does not know, or no code: the server's own sentence, as before.
FakeXHR.instances = [];
const other = start();
FakeXHR.instances[0].responseHeaders = { "x-thoth-upload-error": "A_CODE_FROM_A_NEWER_SERVER" };
FakeXHR.instances[0].complete(409, JSON.stringify({ detail: "staged digest collision" }));
await other.done;
expect(other.result.error).not.toBeInstanceOf(UploadRefused);
expect(other.result.error?.message).toBe("staged digest collision");
await expectNoUploadTimer();
});

it("cleans up after malformed successful JSON", async () => {
const { result, done } = start(); xhr().complete(200, "invalid json"); await done;
expect(result.error).toBeInstanceOf(SyntaxError);
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/api/files.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { message as formatMessage } from "../i18n/formatMessages";
import { UiError } from "../i18n/UiError";
import { isUploadRefusalCode, UPLOAD_ERROR_HEADER, UploadRefused } from "./uploadTypes";
export type StagedFile = {
relative_path: string;
filename: string;
Expand Down Expand Up @@ -62,6 +63,12 @@ export function stageFile(file: File, projectId: string, options: StageFileOptio
xhr.onload = () => {
if (settled) return;
if (xhr.status < 200 || xhr.status >= 300) {
// A refusal the server names by code is said in the screen language, with the file's name.
const code = xhr.getResponseHeader(UPLOAD_ERROR_HEADER);
if (isUploadRefusalCode(code)) {
finish(new UploadRefused(code, file.name));
return;
}
let detail: { detail?: string } | null = null;
try { detail = JSON.parse(xhr.responseText); } catch { /* Preserve the HTTP fallback. */ }
finish(new Error(detail?.detail ?? formatMessage("filePrepareFailed", xhr.status)));
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/api/uploadTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { message } from "../i18n/formatMessages";

/**
* What the server stages (adapters/http/file_staging.py: MEDIA_TYPES, MAX_UPLOAD_BYTES). The screen keeps
* a copy so the file dialog can filter and a wrong file is refused before it is sent; a server test
* compares the two lists.
*/
export const UPLOAD_EXTENSIONS = [".hwpx", ".pdf", ".docx", ".xlsx", ".csv", ".json", ".md", ".txt", ".yaml", ".yml", ".html", ".htm"] as const;
export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
/** The value of the file input's accept attribute. */
export const UPLOAD_ACCEPT = UPLOAD_EXTENSIONS.join(",");
/** The same types as people name them, for sentences. */
export const UPLOAD_FORMATS = "HWPX · PDF · DOCX · XLSX · CSV · JSON · MD · TXT · YAML · HTML";
/** The response header in which the server names why it refused an upload. */
export const UPLOAD_ERROR_HEADER = "x-thoth-upload-error";

const REFUSALS = ["UPLOAD_UNSUPPORTED_TYPE", "UPLOAD_EMPTY", "UPLOAD_TOO_LARGE"] as const;
export type UploadRefusalCode = (typeof REFUSALS)[number];

export function isUploadRefusalCode(value: string | null): value is UploadRefusalCode {
return REFUSALS.some(code => code === value);
}

/** A file that cannot be uploaded: which file and why, said in the screen language at the time it is shown. */
export class UploadRefused extends Error {
constructor(readonly code: UploadRefusalCode, readonly filename: string) {
super(code);
Object.defineProperty(this, "message", { configurable: true, get: () => uploadRefusalText(code, filename) });
}
}

export function uploadRefusalText(code: UploadRefusalCode, filename: string): string {
if (code === "UPLOAD_UNSUPPORTED_TYPE") return message("uploadUnsupported", filename, UPLOAD_FORMATS);
if (code === "UPLOAD_EMPTY") return message("uploadEmpty", filename);
return message("uploadTooLarge", filename);
}

/** The refusal the server would give this file, known before anything is sent. */
export function refusalBeforeUpload(file: Pick<File, "name" | "size">): UploadRefused | null {
const dot = file.name.lastIndexOf(".");
const extension = dot === -1 ? "" : file.name.slice(dot).toLowerCase();
if (!UPLOAD_EXTENSIONS.some(item => item === extension)) return new UploadRefused("UPLOAD_UNSUPPORTED_TYPE", file.name);
if (file.size === 0) return new UploadRefused("UPLOAD_EMPTY", file.name);
if (file.size > MAX_UPLOAD_BYTES) return new UploadRefused("UPLOAD_TOO_LARGE", file.name);
return null;
}

60 changes: 50 additions & 10 deletions apps/web/src/components/ArchivedProjects.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { message } from "../i18n/formatMessages";
import { t, useTranslation } from "../i18n/locale";
import { Button, Callout } from "@blueprintjs/core";
import { Button, Callout, PopoverNext } from "@blueprintjs/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { rpc } from "../api/rpcClient";
Expand All @@ -12,6 +12,45 @@ import type { ProjectSummary } from "../types";
import { ProjectPermanentDelete } from "./ProjectPermanentDelete";
import { useReadAccessProtection } from "./useReadAccessProtection";

/**
* One archived project: its name and the same kind of menu the open projects have. Restoring and deleting
* sit in the menu, so a permanent delete is not one slip away from a restore.
*/
function ArchivedProjectRow({ project, restoring, busy, onRestore, onDelete }: {
project: ProjectSummary;
restoring: boolean;
busy: boolean;
onRestore: () => void;
/** Left out where projects cannot be deleted (the hosted review environment). */
onDelete?: () => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const choose = (action: () => void) => () => {
setMenuOpen(false);
action();
};
const menu = (
<div className="project-inline-menu archived-project-menu" role="menu">
<Button fill alignText="left" minimal role="menuitem" icon="reset" disabled={busy} onClick={choose(onRestore)}>
{t("archive.restore")}
</Button>
{onDelete && (
<Button fill alignText="left" minimal role="menuitem" intent="danger" icon="trash" disabled={busy} onClick={choose(onDelete)}>
{t("archive.delete")}
</Button>
)}
</div>
);
return (
<div className="archived-project-row">
<span className="archived-project-name" title={project.name}>{project.name}</span>
<PopoverNext isOpen={menuOpen} onInteraction={setMenuOpen} placement="right-start" arrow={false} content={menu}>
<Button small minimal icon="more" aria-label={message("projectMenu", project.name)} aria-expanded={menuOpen} loading={restoring} />
</PopoverNext>
</div>
);
}

/** Archived projects are read-only and hidden from the list; this shows them on demand so one can be restored. */
export function ArchivedProjects({ scopeKey, hosted = false, storageScope, onDeleted }: {
scopeKey: string;
Expand Down Expand Up @@ -47,15 +86,16 @@ export function ArchivedProjects({ scopeKey, hosted = false, storageScope, onDel
{!blocked && archived.isPending && <p className="empty-copy" role="status">{t("archive.loading")}</p>}
{(archived.error || blocked) && <Callout compact intent="danger" role="alert">{t("archive.failed")}</Callout>}
{!blocked && archived.isSuccess && rows.length === 0 && <p className="empty-copy">{t("archive.empty")}</p>}
{rows.map(item => <div className="archived-project-row" key={item.project_id}>
<span className="archived-project-name" title={item.name}>{item.name}</span>
<span className="archived-project-actions">
<Button small minimal icon="reset" aria-label={message("restoreProject", item.name)} loading={restore.isPending && restore.variables?.project_id === item.project_id}
disabled={restore.isPending} onClick={() => restore.mutate(item)}>{t("archive.restore")}</Button>
{!hosted && <Button small minimal intent="danger" icon="trash" aria-label={message("deleteProjectLabel", item.name)}
disabled={restore.isPending} onClick={() => { setDeleted(false); setDeleting(item); }}>{t("archive.delete")}</Button>}
</span>
</div>)}
{rows.map(item => (
<ArchivedProjectRow
key={item.project_id}
project={item}
restoring={restore.isPending && restore.variables?.project_id === item.project_id}
busy={restore.isPending}
onRestore={() => restore.mutate(item)}
onDelete={hosted ? undefined : () => { setDeleted(false); setDeleting(item); }}
/>
))}
{!blocked && <ProjectActionNotice error={restore.error} fallbackKey="archive.restoreFailed"/>}
{deleted && !deleting && <p className="empty-copy" role="status">{t("archive.deleteDone")}</p>}
</div>}
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/components/CapabilityWorkbench.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ it("successful file connect resets the native chooser and permits selecting the
Object.defineProperty(input,"files",{configurable:true,value:[file]});
await act(async()=>{
input.dispatchEvent(new Event("change",{bubbles:true}));
const visibility=container.querySelector<HTMLSelectElement>('[aria-label="자료 사용 범위"]')!;
visibility.value="PROJECT_SHARED";visibility.dispatchEvent(new Event("change",{bubbles:true}));
const authority=container.querySelector<HTMLSelectElement>('[aria-label="자료 성격"]')!;
authority.value="INFORMAL";authority.dispatchEvent(new Event("change",{bubbles:true}));
});
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/components/ConnectFailureInWorkspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,6 @@ async function openSourcesAndConnect() {
const input = container.querySelector<HTMLInputElement>('.source-connect-panel input[type="file"]')!;
Object.defineProperty(input, "files", { configurable: true, value: [new File(["x"], "gb.txt")] });
await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); });
const scope = container.querySelector<HTMLSelectElement>('.source-connect-panel select[aria-label="사용 범위"]') ?? [...container.querySelectorAll<HTMLSelectElement>(".source-connect-panel select")].at(-1)!;
await act(async () => { scope.value = "PROJECT_SHARED"; scope.dispatchEvent(new Event("change", { bubbles: true })); });
await act(async () => { ([...container.querySelectorAll(".source-connect-panel button")].find(item => item.textContent === "자료 연결") as HTMLButtonElement).click(); });
}

Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/components/ConnectedSourceCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Button, Collapse } from "@blueprintjs/core";
import { useState } from "react";
import { cutoffLabel } from "../api/sourceTime";
import { t } from "../i18n/locale";
import { isHttpUrl, sourceCardHost, sourceCardTitle } from "../sourceDisplay";
import { RecordInspector } from "./RecordInspector";
import { SourceDisconnectButton } from "./SourceDisconnect";
import { SourceTimeAdvanced } from "./SourceTimeAdvanced";
import type { ProjectSources } from "./useProjectSources";

/** One connected source: what it is, whether it may be cited, and the actions on it. */
export function ConnectedSourceCard({ projectId, artifact, sources, hosted, onCite }: {
projectId: string;
artifact: ProjectSources["artifacts"][number];
sources: ProjectSources | undefined;
hosted: boolean;
onCite: () => void;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
const assessment = (sources?.source_times ?? []).find(item => item.artifact_id === artifact.artifact_id);
const title = sourceCardTitle(artifact.source_uri);
const host = sourceCardHost(artifact.source_uri);
return (
<article className="detail-card">
<h3>{isHttpUrl(artifact.source_uri) ? <a href={artifact.source_uri} target="_blank" rel="noreferrer">{title}</a> : title}</h3>
{host ? <p>{host}</p> : null}
<p>{artifact.media_type}</p>
<p>{cutoffLabel(assessment?.cutoff_state ?? artifact.cutoff_state ?? "UNKNOWN_TIME")}</p>
<Button small onClick={onCite}>{t("workspace.cite")}</Button>
<SourceDisconnectButton projectId={projectId} artifact={artifact} sources={sources} />
{!hosted && <SourceTimeAdvanced projectId={projectId} artifactId={artifact.artifact_id} assessment={assessment} cutoff={sources?.cutoff_basis} />}
{" "}
{!hosted && (
<>
<Button small minimal icon={detailsOpen ? "chevron-up" : "info-sign"} aria-expanded={detailsOpen} onClick={() => setDetailsOpen(value => !value)}>
{t("workspace.sourceDetails")}
</Button>
<Collapse isOpen={detailsOpen}><RecordInspector value={artifact} /></Collapse>
</>
)}
</article>
);
}

Loading
Loading