Пакет веб-багов: карточка, лента, вики-разметка, состояние модулей (#124) #125
@ -524,7 +524,7 @@ describe("вложения в карточке", () => {
|
|||||||
return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over }))));
|
return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over }))));
|
||||||
}
|
}
|
||||||
|
|
||||||
it("раздел «Вложения (2)» после «Описание», новые сверху; размер, автор · дата", async () => {
|
it("раздел «Вложения (2)» после «Описание», порядок хаба; размер, автор · дата", async () => {
|
||||||
hub({});
|
hub({});
|
||||||
open("/jira/issues/AURORA-1650");
|
open("/jira/issues/AURORA-1650");
|
||||||
const sec = within(await screen.findByTestId("attachments"));
|
const sec = within(await screen.findByTestId("attachments"));
|
||||||
@ -532,12 +532,39 @@ describe("вложения в карточке", () => {
|
|||||||
const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent);
|
const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent);
|
||||||
expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1);
|
expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1);
|
||||||
const rows = sec.getAllByRole("listitem");
|
const rows = sec.getAllByRole("listitem");
|
||||||
expect(rows[0]).toHaveTextContent("ОСВ 60.xlsx");
|
expect(rows[1]).toHaveTextContent("ОСВ 60.xlsx");
|
||||||
expect(rows[0]).toHaveTextContent("1,0 КБ");
|
expect(rows[1]).toHaveTextContent("1,0 КБ");
|
||||||
expect(rows[1]).toHaveTextContent("скрин 1.png");
|
expect(rows[0]).toHaveTextContent("скрин 1.png");
|
||||||
expect(rows[1]).toHaveTextContent("234,4 КБ");
|
expect(rows[0]).toHaveTextContent("234,4 КБ");
|
||||||
expect(rows[1]).toHaveTextContent("Иванов Иван · 15.10");
|
expect(rows[0]).toHaveTextContent("Иванов Иван · 15.10");
|
||||||
expect(within(rows[1]).getByRole("button", { name: "Скачать" })).toBeInTheDocument();
|
expect(within(rows[0]).getByRole("button", { name: "Скачать" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("порядок вложений — как у хаба, без пересортировки по дате", async () => {
|
||||||
|
hub({ attachments: [att("1", "b.bin", { created: null }), att("2", "a.bin", { created: "2026-10-20T10:00:00.000+0300" })], comments: [] });
|
||||||
|
open("/jira/issues/AURORA-1650");
|
||||||
|
const rows = within(await screen.findByTestId("attachments")).getAllByRole("listitem");
|
||||||
|
expect(rows[0]).toHaveTextContent("b.bin");
|
||||||
|
expect(rows[1]).toHaveTextContent("a.bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("повтор после ошибки: успех в карточке скрывает прежний failed из несвежего ответа хаба", async () => {
|
||||||
|
let fetched = false;
|
||||||
|
hub({ attachments: [att("31337", "скрин 1.png", { state: "failed", error: "сбой" })], comments: [] }, (_m, url) => {
|
||||||
|
if (url.pathname === "/attachments/31337/fetch") { fetched = true; return json(200, { attachment_id: "31337", state: "ready", error: null, command_id: 1, batch_id: 1 }); }
|
||||||
|
if (url.pathname === "/attachments/31337/content") return new Response("PNG", { status: 200 });
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
URL.createObjectURL = vi.fn(() => "blob:fake");
|
||||||
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||||
|
open("/jira/issues/AURORA-1650");
|
||||||
|
const sec = within(await screen.findByTestId("attachments"));
|
||||||
|
expect(await sec.findByText("сбой")).toBeInTheDocument();
|
||||||
|
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
|
||||||
|
await waitFor(() => expect(fetched).toBe(true));
|
||||||
|
await waitFor(() => expect(sec.queryByText("сбой")).toBeNull());
|
||||||
|
expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("без поля attachments (старый хаб) раздела нет", async () => {
|
it("без поля attachments (старый хаб) раздела нет", async () => {
|
||||||
@ -596,7 +623,7 @@ describe("вложения в карточке", () => {
|
|||||||
? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined);
|
? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined);
|
||||||
open("/jira/issues/AURORA-1650");
|
open("/jira/issues/AURORA-1650");
|
||||||
const sec = within(await screen.findByTestId("attachments"));
|
const sec = within(await screen.findByTestId("attachments"));
|
||||||
await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[1]);
|
await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[0]);
|
||||||
expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument();
|
expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument();
|
||||||
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
|
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
|
||||||
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2);
|
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2);
|
||||||
|
|||||||
@ -10,19 +10,13 @@ export type Download = ReturnType<typeof useAttachmentDownload>;
|
|||||||
const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile };
|
const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile };
|
||||||
const KIND_LABEL = { image: "картинка", sheet: "таблица", pdf: "PDF", other: "файл" };
|
const KIND_LABEL = { image: "картинка", sheet: "таблица", pdf: "PDF", other: "файл" };
|
||||||
|
|
||||||
// новые сверху: по времени Jira, затем по числовому id
|
|
||||||
export function newestFirst(items: IssueAttachment[]): IssueAttachment[] {
|
|
||||||
const t = (a: IssueAttachment) => (a.created ? Date.parse(a.created) || 0 : 0);
|
|
||||||
return [...items].sort((a, b) => t(b) - t(a) || (Number(b.attachment_id) || 0) - (Number(a.attachment_id) || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// раздел «Вложения (N)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания
|
// раздел «Вложения (N)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания
|
||||||
export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) {
|
export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) {
|
||||||
return (
|
return (
|
||||||
<section className="attachments" data-testid="attachments">
|
<section className="attachments" data-testid="attachments">
|
||||||
<div className="sec-head"><h3>Вложения ({items.length})</h3></div>
|
<div className="sec-head"><h3>Вложения ({items.length})</h3></div>
|
||||||
<ul className="att-list">
|
<ul className="att-list">
|
||||||
{newestFirst(items).map((a) => <Row key={a.attachment_id} a={a} dl={dl} />)}
|
{items.map((a) => <Row key={a.attachment_id} a={a} dl={dl} />)}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@ -31,9 +25,9 @@ export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Do
|
|||||||
function Row({ a, dl }: { a: IssueAttachment; dl: Download }) {
|
function Row({ a, dl }: { a: IssueAttachment; dl: Download }) {
|
||||||
const kind = fileKind(a.mime_type, a.filename);
|
const kind = fileKind(a.mime_type, a.filename);
|
||||||
const Icon = KIND_ICON[kind];
|
const Icon = KIND_ICON[kind];
|
||||||
const { busy, error: local } = dl.status(a.attachment_id);
|
const { busy, error: local, ok } = dl.status(a.attachment_id);
|
||||||
// ошибка: своя (последняя попытка в этой карточке) или команда, закончившаяся failed до открытия карточки
|
// ошибка: своя (последняя попытка в этой карточке) или команда, закончившаяся failed до открытия карточки
|
||||||
const error = busy ? null : local ?? (a.state === "failed" ? a.error ?? "не удалось скачать вложение" : null);
|
const error = busy ? null : local ?? (a.state === "failed" && !ok ? a.error ?? "не удалось скачать вложение" : null);
|
||||||
const who = [a.author, a.created ? fmtStamp(a.created) : null].filter(Boolean).join(" · ");
|
const who = [a.author, a.created ? fmtStamp(a.created) : null].filter(Boolean).join(" · ");
|
||||||
return (
|
return (
|
||||||
<li className="att-row">
|
<li className="att-row">
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { makeQueryClient } from "../../../../api/queryClient.ts";
|
import { makeQueryClient } from "../../../../api/queryClient.ts";
|
||||||
import type { IssueAttachment } from "../../api/types.ts";
|
import type { IssueAttachment } from "../../api/types.ts";
|
||||||
import { setToken } from "../../../../auth/token.ts";
|
import { getToken, setToken } from "../../../../auth/token.ts";
|
||||||
import { json, mockFetch } from "../../../../test/fetchMock.ts";
|
import { json, mockFetch } from "../../../../test/fetchMock.ts";
|
||||||
import { useAttachmentDownload } from "./useAttachmentDownload.ts";
|
import { useAttachmentDownload } from "./useAttachmentDownload.ts";
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ describe("useAttachmentDownload", () => {
|
|||||||
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||||
act(() => result.current.start(att));
|
act(() => result.current.start(att));
|
||||||
await flush();
|
await flush();
|
||||||
expect(result.current.status("31337")).toEqual({ busy: true, error: null });
|
expect(result.current.status("31337")).toEqual({ busy: true, error: null, ok: false });
|
||||||
expect(JSON.parse(String(f.mock.calls[0][1]?.body))).toEqual({ author: "web" });
|
expect(JSON.parse(String(f.mock.calls[0][1]?.body))).toEqual({ author: "web" });
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||||
expect(polls).toBe(1);
|
expect(polls).toBe(1);
|
||||||
@ -48,7 +48,7 @@ describe("useAttachmentDownload", () => {
|
|||||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled(); // I-2: отзыв отложен
|
expect(URL.revokeObjectURL).not.toHaveBeenCalled(); // I-2: отзыв отложен
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
|
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake");
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake");
|
||||||
expect(result.current.status("31337")).toEqual({ busy: false, error: null });
|
expect(result.current.status("31337")).toEqual({ busy: false, error: null, ok: true });
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
|
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
|
||||||
expect(polls).toBe(2);
|
expect(polls).toBe(2);
|
||||||
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1);
|
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1);
|
||||||
@ -84,7 +84,7 @@ describe("useAttachmentDownload", () => {
|
|||||||
const n = f.mock.calls.length;
|
const n = f.mock.calls.length;
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
|
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
|
||||||
expect(f.mock.calls.length).toBe(n);
|
expect(f.mock.calls.length).toBe(n);
|
||||||
expect(result.current.status("31337")).toEqual({ busy: false, error: null });
|
expect(result.current.status("31337")).toEqual({ busy: false, error: null, ok: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("409 — ошибка с текстом хаба", async () => {
|
it("409 — ошибка с текстом хаба", async () => {
|
||||||
@ -92,20 +92,39 @@ describe("useAttachmentDownload", () => {
|
|||||||
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||||
act(() => result.current.start(att));
|
act(() => result.current.start(att));
|
||||||
await flush();
|
await flush();
|
||||||
expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14" });
|
expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14", ok: false });
|
||||||
expect(clicked).toHaveLength(0);
|
expect(clicked).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => {
|
it("401 при опросе — сброс токена, без текста ошибки", async () => {
|
||||||
let n = 0;
|
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(401, { error: "unauthorized" }));
|
||||||
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404" }) : st("fetching")));
|
|
||||||
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||||
act(() => result.current.start(att));
|
act(() => result.current.start(att));
|
||||||
await flush();
|
await flush();
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||||
expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404" });
|
expect(getToken()).toBe("");
|
||||||
|
expect(result.current.status("31337").error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сеть недоступна при опросе — «хаб не отвечает»", async () => {
|
||||||
|
mockFetch((m) => { if (m === "POST") return json(200, st("fetching")); throw new TypeError("fetch failed"); });
|
||||||
|
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||||
act(() => result.current.start(att));
|
act(() => result.current.start(att));
|
||||||
await flush();
|
await flush();
|
||||||
expect(result.current.status("31337")).toEqual({ busy: true, error: null });
|
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||||
|
expect(result.current.status("31337").error).toBe("хаб не отвечает");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => {
|
||||||
|
let n = 0;
|
||||||
|
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404", ok: false }) : st("fetching")));
|
||||||
|
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||||
|
act(() => result.current.start(att));
|
||||||
|
await flush();
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||||
|
expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404", ok: false });
|
||||||
|
act(() => result.current.start(att));
|
||||||
|
await flush();
|
||||||
|
expect(result.current.status("31337")).toEqual({ busy: true, error: null, ok: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ApiError } from "../../../../api/client.ts";
|
import { ApiError, AuthError, dropTokenOn, HubDownError } from "../../../../api/client.ts";
|
||||||
import { api } from "../../../../api/client.ts";
|
import { api } from "../../../../api/client.ts";
|
||||||
import { qk } from "../../api/queries.ts";
|
import { qk } from "../../api/queries.ts";
|
||||||
import type { AttachmentState, IssueAttachment } from "../../api/types.ts";
|
import type { AttachmentState, IssueAttachment } from "../../api/types.ts";
|
||||||
|
|
||||||
export const POLL_MS = 2000;
|
export const POLL_MS = 2000;
|
||||||
export interface DownloadStatus { busy: boolean; error: string | null }
|
export interface DownloadStatus { busy: boolean; error: string | null; ok: boolean } // ok — успех в этой карточке
|
||||||
const IDLE: DownloadStatus = { busy: false, error: null };
|
const IDLE: DownloadStatus = { busy: false, error: null, ok: false };
|
||||||
|
|
||||||
// браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется)
|
// браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется)
|
||||||
function save(blob: Blob, filename: string) {
|
function save(blob: Blob, filename: string) {
|
||||||
@ -56,7 +56,12 @@ export function useAttachmentDownload(issueKey: string) {
|
|||||||
if (mapRef.current[id]?.busy) return;
|
if (mapRef.current[id]?.busy) return;
|
||||||
const my = gen.current;
|
const my = gen.current;
|
||||||
const alive = () => gen.current === my;
|
const alive = () => gen.current === my;
|
||||||
const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); };
|
const fail = (error: string) => { if (alive()) put(id, { busy: false, error, ok: false }); };
|
||||||
|
// 401 — экран входа без текста ошибки; сеть/5xx — «хаб не отвечает»; прочее — текст ошибки
|
||||||
|
const onError = (e: unknown) => {
|
||||||
|
if (e instanceof AuthError) { dropTokenOn(e); if (alive()) put(id, IDLE); }
|
||||||
|
else fail(e instanceof HubDownError ? "хаб не отвечает" : errorText(e));
|
||||||
|
};
|
||||||
|
|
||||||
// разбор состояния: ready — забрать файл, fetching — подождать и спросить снова, иначе — ошибка
|
// разбор состояния: ready — забрать файл, fetching — подождать и спросить снова, иначе — ошибка
|
||||||
const handle = async (st: AttachmentState): Promise<void> => {
|
const handle = async (st: AttachmentState): Promise<void> => {
|
||||||
@ -65,13 +70,13 @@ export function useAttachmentDownload(issueKey: string) {
|
|||||||
const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`);
|
const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`);
|
||||||
if (!alive()) return;
|
if (!alive()) return;
|
||||||
save(blob, a.filename);
|
save(blob, a.filename);
|
||||||
put(id, IDLE);
|
put(id, { busy: false, error: null, ok: true });
|
||||||
void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба»
|
void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба»
|
||||||
} else if (st.state === "fetching") {
|
} else if (st.state === "fetching") {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
timers.current.delete(t);
|
timers.current.delete(t);
|
||||||
if (!alive()) return;
|
if (!alive()) return;
|
||||||
api.get<AttachmentState>(`/attachments/${encodeURIComponent(id)}`).then(handle).catch((e) => fail(errorText(e)));
|
api.get<AttachmentState>(`/attachments/${encodeURIComponent(id)}`).then(handle).catch(onError);
|
||||||
}, POLL_MS);
|
}, POLL_MS);
|
||||||
timers.current.add(t);
|
timers.current.add(t);
|
||||||
} else {
|
} else {
|
||||||
@ -79,9 +84,9 @@ export function useAttachmentDownload(issueKey: string) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
put(id, { busy: true, error: null });
|
put(id, { busy: true, error: null, ok: false });
|
||||||
api.post<AttachmentState>(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" })
|
api.post<AttachmentState>(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" })
|
||||||
.then(handle).catch((e) => fail(errorText(e)));
|
.then(handle).catch(onError);
|
||||||
}, [issueKey, put, qc]);
|
}, [issueKey, put, qc]);
|
||||||
|
|
||||||
const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]);
|
const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]);
|
||||||
|
|||||||
@ -89,13 +89,17 @@ describe("parseInline: ссылки на вложения", () => {
|
|||||||
expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), att("31337", t("скрин 1.png"))]);
|
expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), att("31337", t("скрин 1.png"))]);
|
||||||
expect(parseInline("!скрин 1.png!", A)).toEqual([att("31337", t("скрин 1.png"))]);
|
expect(parseInline("!скрин 1.png!", A)).toEqual([att("31337", t("скрин 1.png"))]);
|
||||||
});
|
});
|
||||||
|
it("два вложения с одним именем — первое в порядке хаба, даже если created null", () => {
|
||||||
|
const o = { attachments: [{ id: "5", filename: "скрин.png", created: null }, { id: "9", filename: "скрин.png", created: "2026-10-20T10:00:00.000+0300" }] };
|
||||||
|
expect(parseInline("!скрин.png!", o)).toEqual([att("5", t("скрин.png"))]);
|
||||||
|
});
|
||||||
it("[^имя] и [текст|^имя]", () => {
|
it("[^имя] и [текст|^имя]", () => {
|
||||||
expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]);
|
expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]);
|
||||||
expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]);
|
expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]);
|
||||||
});
|
});
|
||||||
it("имя точно (с регистром); несколько с одним именем — самое новое", () => {
|
it("имя точно (с регистром); несколько с одним именем — первое в порядке хаба", () => {
|
||||||
expect(parseInline("!Скрин 1.png!", A)).toEqual([t("!Скрин 1.png!")]);
|
expect(parseInline("!Скрин 1.png!", A)).toEqual([t("!Скрин 1.png!")]);
|
||||||
expect(parseInline("!дубль.png!", A)).toEqual([att("20", t("дубль.png"))]);
|
expect(parseInline("!дубль.png!", A)).toEqual([att("10", t("дубль.png"))]); // первое в порядке хаба
|
||||||
});
|
});
|
||||||
it("имени нет среди вложений — исходник как есть", () => {
|
it("имени нет среди вложений — исходник как есть", () => {
|
||||||
expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]);
|
expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]);
|
||||||
|
|||||||
@ -32,16 +32,9 @@ function safeHref(raw: string): string | null {
|
|||||||
return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null;
|
return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// вложение по имени: несколько с одним именем — самое новое по created, затем по числовому id
|
// вложение по имени: несколько с одним именем — первое в порядке хаба
|
||||||
function findAttachment(opts: WikiOptions, name: string): string | null {
|
function findAttachment(opts: WikiOptions, name: string): string | null {
|
||||||
const same = (opts.attachments ?? []).filter((a) => a.filename === name);
|
return (opts.attachments ?? []).find((a) => a.filename === name)?.id ?? null;
|
||||||
if (!same.length) return null;
|
|
||||||
const rank = (a: WikiAttachment): [number, number] => [a.created ? Date.parse(a.created) || 0 : 0, Number(a.id) || 0];
|
|
||||||
return same.reduce((best, a) => {
|
|
||||||
const [bt, bi] = rank(best);
|
|
||||||
const [at, ai] = rank(a);
|
|
||||||
return at > bt || (at === bt && ai > bi) ? a : best;
|
|
||||||
}).id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyPattern(projectKey: string | undefined): RegExp | null {
|
function keyPattern(projectKey: string | undefined): RegExp | null {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user