Harden IPC handlers: error protocol on exports/imports/settings, menu null-title crash fix, recents dedupe by path, no silent permanent book delete
This commit is contained in:
parent
c5c7c80b81
commit
ed3f3cd84b
4 changed files with 176 additions and 111 deletions
|
|
@ -201,48 +201,59 @@ function sanitizeName(name: string): string {
|
||||||
ipcMain.handle("folio:newBookNamed", async (_e, name: string) => {
|
ipcMain.handle("folio:newBookNamed", async (_e, name: string) => {
|
||||||
const title = (name || "").trim();
|
const title = (name || "").trim();
|
||||||
if (!title) return { error: "Please provide a book name." };
|
if (!title) return { error: "Please provide a book name." };
|
||||||
const base = app.getPath("documents");
|
try {
|
||||||
const folder = sanitizeName(title) || "Untitled";
|
const base = app.getPath("documents");
|
||||||
let target = path.join(base, folder);
|
const folder = sanitizeName(title) || "Untitled";
|
||||||
let n = 2;
|
let target = path.join(base, folder);
|
||||||
while (hasBook(target) || fs.existsSync(target)) {
|
let n = 2;
|
||||||
target = path.join(base, `${folder} ${n}`);
|
while (hasBook(target) || fs.existsSync(target)) {
|
||||||
n++;
|
target = path.join(base, `${folder} ${n}`);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
const meta = createBook(target, title);
|
||||||
|
if (process.env.FOLIO_SELF_TEST !== "1") {
|
||||||
|
addRecent({ path: target, title: meta.title, lastOpened: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
bookPath = target;
|
||||||
|
mainWindow?.setTitle(`${meta.title} — Folio`);
|
||||||
|
refreshMenu();
|
||||||
|
getWindow()?.webContents.send("folio:bookOpened", { path: target, title: meta.title });
|
||||||
|
return { path: target, title: meta.title };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err instanceof Error ? err.message : String(err) };
|
||||||
}
|
}
|
||||||
const meta = createBook(target, title);
|
|
||||||
if (process.env.FOLIO_SELF_TEST !== "1") {
|
|
||||||
addRecent({ path: target, title: meta.title, lastOpened: new Date().toISOString() });
|
|
||||||
}
|
|
||||||
bookPath = target;
|
|
||||||
mainWindow?.setTitle(`${meta.title} — Folio`);
|
|
||||||
refreshMenu();
|
|
||||||
getWindow()?.webContents.send("folio:bookOpened", { path: target, title: meta.title });
|
|
||||||
return { path: target, title: meta.title };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("folio:deleteBook", async (_e, p: string) => {
|
ipcMain.handle("folio:deleteBook", async (_e, p: string) => {
|
||||||
try {
|
try {
|
||||||
// Always drop the recent entry, even if the folder is gone or was moved,
|
|
||||||
// so stale/missing books can be cleared from the list.
|
|
||||||
removeRecent(p);
|
|
||||||
if (!hasBook(p)) {
|
if (!hasBook(p)) {
|
||||||
|
// Stale recent entry: nothing to trash, just clear it from the list.
|
||||||
|
removeRecent(p);
|
||||||
if (bookPath === p) bookPath = null;
|
if (bookPath === p) bookPath = null;
|
||||||
refreshMenu();
|
refreshMenu();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
// Move to the system trash rather than permanently erasing, so an
|
// Move to the system trash rather than permanently erasing. If the
|
||||||
// accidental delete can be undone from the desktop trash.
|
// trash refuses, STOP and report instead of silently destroying a whole
|
||||||
|
// book directory — an unrecoverable rmSync here used to masquerade as a
|
||||||
|
// safe "move to trash".
|
||||||
|
let trashed = false;
|
||||||
try {
|
try {
|
||||||
await shell.trashItem(p);
|
await shell.trashItem(p);
|
||||||
|
trashed = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to a permanent delete only when the system trash is
|
return {
|
||||||
// unavailable (e.g. headless environments). On a normal desktop the
|
error:
|
||||||
// book is moved to the trash and remains recoverable.
|
"Could not move the book to the system trash, so it was left untouched. " +
|
||||||
fs.rmSync(p, { recursive: true, force: true });
|
"Delete it manually from your file manager if you are sure.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
removeRecent(p);
|
||||||
if (bookPath === p) bookPath = null;
|
if (bookPath === p) bookPath = null;
|
||||||
refreshMenu();
|
refreshMenu();
|
||||||
return { ok: true };
|
// In-app Undo reads the Linux home trash layout; on other platforms the
|
||||||
|
// OS trash is still recoverable by hand, so no false promise is made.
|
||||||
|
return { ok: true, trashed, canUndoRestore: process.platform === "linux" };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: (e as Error).message };
|
return { error: (e as Error).message };
|
||||||
}
|
}
|
||||||
|
|
@ -289,8 +300,14 @@ ipcMain.handle("folio:getRecents", (): Recent[] => listRecents());
|
||||||
ipcMain.handle("folio:closeBook", () => {
|
ipcMain.handle("folio:closeBook", () => {
|
||||||
bookPath = null;
|
bookPath = null;
|
||||||
mainWindow?.setTitle("Folio");
|
mainWindow?.setTitle("Folio");
|
||||||
refreshMenu();
|
// Notify the renderer BEFORE rebuilding the menu: a refreshMenu failure
|
||||||
|
// must not leave main with no book while the renderer still shows one.
|
||||||
getWindow()?.webContents.send("folio:bookClosed");
|
getWindow()?.webContents.send("folio:bookClosed");
|
||||||
|
try {
|
||||||
|
refreshMenu();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[folio] refreshMenu after close failed:", err);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -457,28 +474,36 @@ function currentBookTitle(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle("folio:exportMarkdown", async () => {
|
ipcMain.handle("folio:exportMarkdown", async () => {
|
||||||
const bp = requireBook();
|
try {
|
||||||
const { canceled, filePath } = await showSaveDialog({
|
const bp = requireBook();
|
||||||
title: "Export combined Markdown",
|
const { canceled, filePath } = await showSaveDialog({
|
||||||
defaultPath: `${currentBookTitle()}.md`,
|
title: "Export combined Markdown",
|
||||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
defaultPath: `${currentBookTitle()}.md`,
|
||||||
});
|
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||||
if (canceled || !filePath) return { canceled: true } as const;
|
});
|
||||||
fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8");
|
if (canceled || !filePath) return { canceled: true } as const;
|
||||||
return { ok: true, filePath };
|
fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8");
|
||||||
|
return { ok: true, filePath };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("folio:exportZip", async () => {
|
ipcMain.handle("folio:exportZip", async () => {
|
||||||
const bp = requireBook();
|
try {
|
||||||
const { canceled, filePath } = await showSaveDialog({
|
const bp = requireBook();
|
||||||
title: "Export book bundle (ZIP)",
|
const { canceled, filePath } = await showSaveDialog({
|
||||||
defaultPath: `${currentBookTitle()}.zip`,
|
title: "Export book bundle (ZIP)",
|
||||||
filters: [{ name: "ZIP Archive", extensions: ["zip"] }],
|
defaultPath: `${currentBookTitle()}.zip`,
|
||||||
});
|
filters: [{ name: "ZIP Archive", extensions: ["zip"] }],
|
||||||
if (canceled || !filePath) return { canceled: true } as const;
|
});
|
||||||
const buf = await buildZip(bp);
|
if (canceled || !filePath) return { canceled: true } as const;
|
||||||
fs.writeFileSync(filePath, buf);
|
const buf = await buildZip(bp);
|
||||||
return { ok: true, filePath };
|
fs.writeFileSync(filePath, buf);
|
||||||
|
return { ok: true, filePath };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export the Full Book view as a PDF. The renderer shows only the assembled
|
// Export the Full Book view as a PDF. The renderer shows only the assembled
|
||||||
|
|
@ -489,10 +514,13 @@ ipcMain.handle("folio:exportZip", async () => {
|
||||||
ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => {
|
ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => {
|
||||||
try {
|
try {
|
||||||
requireBook();
|
requireBook();
|
||||||
const selected = Array.isArray(chapterIds) && chapterIds.length > 0;
|
// An explicitly empty selection is an error; undefined/null means the
|
||||||
if (selected && chapterIds!.length === 0) {
|
// whole book. The old guard checked length>0 before comparing to zero,
|
||||||
|
// so it could never fire and empty arrays fell through to a full export.
|
||||||
|
if (Array.isArray(chapterIds) && chapterIds.length === 0) {
|
||||||
return { error: "Select at least one chapter to export." };
|
return { error: "Select at least one chapter to export." };
|
||||||
}
|
}
|
||||||
|
const selected = Array.isArray(chapterIds) && chapterIds.length > 0;
|
||||||
const base = currentBookTitle();
|
const base = currentBookTitle();
|
||||||
const suffix = selected ? " - Selected Chapters" : "";
|
const suffix = selected ? " - Selected Chapters" : "";
|
||||||
let filePath: string;
|
let filePath: string;
|
||||||
|
|
@ -529,10 +557,12 @@ ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => {
|
||||||
async function exportOffice(format: "docx" | "odt", chapterIds?: string[] | null) {
|
async function exportOffice(format: "docx" | "odt", chapterIds?: string[] | null) {
|
||||||
try {
|
try {
|
||||||
const bp = requireBook();
|
const bp = requireBook();
|
||||||
const selected = Array.isArray(chapterIds) && chapterIds.length > 0;
|
// Same fix as the PDF path: an explicitly empty array is an error,
|
||||||
if (selected && chapterIds!.length === 0) {
|
// undefined/null means the whole book.
|
||||||
|
if (Array.isArray(chapterIds) && chapterIds.length === 0) {
|
||||||
return { error: "Select at least one chapter to export." };
|
return { error: "Select at least one chapter to export." };
|
||||||
}
|
}
|
||||||
|
const selected = Array.isArray(chapterIds) && chapterIds.length > 0;
|
||||||
const ext = format;
|
const ext = format;
|
||||||
const envKey = format === "docx" ? "FOLIO_TEST_DOCX" : "FOLIO_TEST_ODT";
|
const envKey = format === "docx" ? "FOLIO_TEST_DOCX" : "FOLIO_TEST_ODT";
|
||||||
const base = currentBookTitle();
|
const base = currentBookTitle();
|
||||||
|
|
@ -622,30 +652,34 @@ ipcMain.handle("folio:saveChapterDoc", (_e, id: string, doc: unknown) => {
|
||||||
// Copy a user-chosen image into the book's assets/ directory and return a path
|
// Copy a user-chosen image into the book's assets/ directory and return a path
|
||||||
// relative to the book root (e.g. "assets/photo.png") for embedding.
|
// relative to the book root (e.g. "assets/photo.png") for embedding.
|
||||||
ipcMain.handle("folio:importImageAsset", async () => {
|
ipcMain.handle("folio:importImageAsset", async () => {
|
||||||
const bp = requireBook();
|
try {
|
||||||
const { canceled, filePaths } = await showOpenDialog({
|
const bp = requireBook();
|
||||||
title: "Insert image",
|
const { canceled, filePaths } = await showOpenDialog({
|
||||||
properties: ["openFile"],
|
title: "Insert image",
|
||||||
filters: [
|
properties: ["openFile"],
|
||||||
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] },
|
filters: [
|
||||||
],
|
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] },
|
||||||
});
|
],
|
||||||
if (canceled || !filePaths.length) return { canceled: true } as const;
|
});
|
||||||
const src = filePaths[0];
|
if (canceled || !filePaths.length) return { canceled: true } as const;
|
||||||
const dir = path.join(bp, "assets");
|
const src = filePaths[0];
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
const dir = path.join(bp, "assets");
|
||||||
const base = path.basename(src);
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
const ext = path.extname(base);
|
const base = path.basename(src);
|
||||||
const stem = path.basename(base, ext);
|
const ext = path.extname(base);
|
||||||
let name = base;
|
const stem = path.basename(base, ext);
|
||||||
let counter = 2;
|
let name = base;
|
||||||
while (fs.existsSync(path.join(dir, name))) {
|
let counter = 2;
|
||||||
name = `${stem}-${counter}${ext}`;
|
while (fs.existsSync(path.join(dir, name))) {
|
||||||
counter += 1;
|
name = `${stem}-${counter}${ext}`;
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
fs.copyFileSync(src, path.join(dir, name));
|
||||||
|
const relPath = `assets/${name}`;
|
||||||
|
return { ok: true, name, relPath };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err instanceof Error ? err.message : String(err) };
|
||||||
}
|
}
|
||||||
fs.copyFileSync(src, path.join(dir, name));
|
|
||||||
const relPath = `assets/${name}`;
|
|
||||||
return { ok: true, name, relPath };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("folio:copyText", (_e, text: string) => {
|
ipcMain.handle("folio:copyText", (_e, text: string) => {
|
||||||
|
|
@ -686,10 +720,14 @@ ipcMain.on("folio:context-menu", (event, x: number, y: number) => {
|
||||||
// "light" in the renderer, so it is normalized here too.
|
// "light" in the renderer, so it is normalized here too.
|
||||||
ipcMain.handle("folio:getTheme", () => getSetting("theme", "light"));
|
ipcMain.handle("folio:getTheme", () => getSetting("theme", "light"));
|
||||||
ipcMain.handle("folio:setTheme", (_e, id: unknown) => {
|
ipcMain.handle("folio:setTheme", (_e, id: unknown) => {
|
||||||
const theme =
|
try {
|
||||||
typeof id === "string" && id && id !== "default" ? id : "light";
|
const theme =
|
||||||
setSetting("theme", theme);
|
typeof id === "string" && id && id !== "default" ? id : "light";
|
||||||
return { ok: true };
|
setSetting("theme", theme);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editor zoom preference (percentage). Persisted app-wide, not per book, so the
|
// Editor zoom preference (percentage). Persisted app-wide, not per book, so the
|
||||||
|
|
@ -699,9 +737,13 @@ ipcMain.handle("folio:getZoom", () => {
|
||||||
return Number.isFinite(z) && z > 0 ? Math.round(z) : 100;
|
return Number.isFinite(z) && z > 0 ? Math.round(z) : 100;
|
||||||
});
|
});
|
||||||
ipcMain.handle("folio:setZoom", (_e, pct: unknown) => {
|
ipcMain.handle("folio:setZoom", (_e, pct: unknown) => {
|
||||||
const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100;
|
try {
|
||||||
setSetting("zoom", z);
|
const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100;
|
||||||
return { ok: true };
|
setSetting("zoom", z);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Backup location preference. Backup reads/writes settings through the
|
// Backup location preference. Backup reads/writes settings through the
|
||||||
|
|
@ -709,31 +751,39 @@ ipcMain.handle("folio:setZoom", (_e, pct: unknown) => {
|
||||||
bindBackupSettings({ get: getSetting, set: setSetting });
|
bindBackupSettings({ get: getSetting, set: setSetting });
|
||||||
ipcMain.handle("folio:getBackupLocation", () => getRecoveryRoot());
|
ipcMain.handle("folio:getBackupLocation", () => getRecoveryRoot());
|
||||||
ipcMain.handle("folio:setBackupLocation", (_e, loc: unknown) => {
|
ipcMain.handle("folio:setBackupLocation", (_e, loc: unknown) => {
|
||||||
const s = typeof loc === "string" ? loc.trim() : "";
|
try {
|
||||||
setRecoveryRoot(s);
|
const s = typeof loc === "string" ? loc.trim() : "";
|
||||||
return { ok: true };
|
setRecoveryRoot(s);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("folio:importAttachment", async () => {
|
ipcMain.handle("folio:importAttachment", async () => {
|
||||||
const bp = requireBook();
|
try {
|
||||||
const { canceled, filePaths } = await showOpenDialog({
|
const bp = requireBook();
|
||||||
title: "Attach a file",
|
const { canceled, filePaths } = await showOpenDialog({
|
||||||
properties: ["openFile"],
|
title: "Attach a file",
|
||||||
});
|
properties: ["openFile"],
|
||||||
if (canceled || !filePaths.length) return { canceled: true } as const;
|
});
|
||||||
const src = filePaths[0];
|
if (canceled || !filePaths.length) return { canceled: true } as const;
|
||||||
const dir = path.join(bp, "attachments");
|
const src = filePaths[0];
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
const dir = path.join(bp, "attachments");
|
||||||
const base = path.basename(src);
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
const ext = path.extname(base);
|
const base = path.basename(src);
|
||||||
const stem = path.basename(base, ext);
|
const ext = path.extname(base);
|
||||||
let name = base;
|
const stem = path.basename(base, ext);
|
||||||
let counter = 2;
|
let name = base;
|
||||||
while (fs.existsSync(path.join(dir, name))) {
|
let counter = 2;
|
||||||
name = `${stem}-${counter}${ext}`;
|
while (fs.existsSync(path.join(dir, name))) {
|
||||||
counter += 1;
|
name = `${stem}-${counter}${ext}`;
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
fs.copyFileSync(src, path.join(dir, name));
|
||||||
|
const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/");
|
||||||
|
return { ok: true, name, relPath };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err instanceof Error ? err.message : String(err) };
|
||||||
}
|
}
|
||||||
fs.copyFileSync(src, path.join(dir, name));
|
|
||||||
const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/");
|
|
||||||
return { ok: true, name, relPath };
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,19 @@ export function buildMenu(
|
||||||
const recents = listRecents();
|
const recents = listRecents();
|
||||||
|
|
||||||
const recentItems: MenuItem[] = recents.length
|
const recentItems: MenuItem[] = recents.length
|
||||||
? recents.map((r: Recent) => ({
|
? recents.map((r: Recent) => {
|
||||||
label: r.title.length ? `${r.title} — ${path.dirname(r.path)}` : path.basename(r.path),
|
// A folio.json with a null/missing title used to throw here and,
|
||||||
tooltip: r.path,
|
// at startup, take the whole app down before any window existed.
|
||||||
click: () => getWindow()?.webContents.send("folio:open-recent", r.path),
|
const title = typeof r.title === "string" ? r.title.trim() : "";
|
||||||
}))
|
const label = title
|
||||||
|
? `${title} — ${path.dirname(r.path)}`
|
||||||
|
: path.basename(r.path);
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
tooltip: r.path,
|
||||||
|
click: () => getWindow()?.webContents.send("folio:open-recent", r.path),
|
||||||
|
};
|
||||||
|
})
|
||||||
: [{ label: "(no recent books)", enabled: false }];
|
: [{ label: "(no recent books)", enabled: false }];
|
||||||
|
|
||||||
const fileMenu: MenuItem = {
|
const fileMenu: MenuItem = {
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,13 @@ export function listRecents(): (Recent & { missing?: boolean })[] {
|
||||||
// Keep every saved entry so a book never "disappears" from the list just
|
// Keep every saved entry so a book never "disappears" from the list just
|
||||||
// because its folder moved or was renamed. Mark entries whose book can't be
|
// because its folder moved or was renamed. Mark entries whose book can't be
|
||||||
// found so the UI can show them as missing instead of hiding them.
|
// found so the UI can show them as missing instead of hiding them.
|
||||||
|
// Dedupe by PATH: keying on title used to hide genuinely distinct books
|
||||||
|
// that happened to share a display name ("Untitled"), making one of them
|
||||||
|
// unreachable from the recents menu.
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const deduped: (Recent & { missing?: boolean })[] = [];
|
const deduped: (Recent & { missing?: boolean })[] = [];
|
||||||
for (const r of loadRaw(recentsFilePath())) {
|
for (const r of loadRaw(recentsFilePath())) {
|
||||||
const key = (r.title || r.path).toLowerCase();
|
const key = path.resolve(r.path);
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
deduped.push({ ...r, missing: !hasBook(r.path) });
|
deduped.push({ ...r, missing: !hasBook(r.path) });
|
||||||
|
|
|
||||||
|
|
@ -1803,6 +1803,10 @@ const tmp = createTiptapEditor(host);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await renderRecents();
|
await renderRecents();
|
||||||
|
// Only promise an in-app Undo where restore actually works (Linux
|
||||||
|
// home trash). Elsewhere the book sits in the OS trash, which the
|
||||||
|
// user can restore manually — no false "Undo" button.
|
||||||
|
if (!(res as { canUndoRestore?: boolean }).canUndoRestore) return;
|
||||||
showUndoToast(`Deleted "${r.title || r.path}"`, async () => {
|
showUndoToast(`Deleted "${r.title || r.path}"`, async () => {
|
||||||
const rr = await api.restoreBook(r.path);
|
const rr = await api.restoreBook(r.path);
|
||||||
if (rr && "error" in rr) {
|
if (rr && "error" in rr) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue