feat(recents): Hide action on recents rows — list-only, never touches the book on disk; 'Hidden (n)' disclosure to unhide; re-opening a book un-hides it; hidden entries dropped from Open Recent menu
This commit is contained in:
parent
da9024fa64
commit
3e6962eda9
9 changed files with 264 additions and 79 deletions
|
|
@ -17,7 +17,7 @@ import {
|
||||||
saveMeta,
|
saveMeta,
|
||||||
Recent,
|
Recent,
|
||||||
} from "./project.js";
|
} from "./project.js";
|
||||||
import { listRecents, addRecent, removeRecent, renameRecent, migrateLegacyRecents } from "./recents.js";
|
import { listRecents, addRecent, removeRecent, renameRecent, hideRecent, unhideRecent, migrateLegacyRecents } from "./recents.js";
|
||||||
import { buildMenu } from "./menu.js";
|
import { buildMenu } from "./menu.js";
|
||||||
import {
|
import {
|
||||||
createChapter,
|
createChapter,
|
||||||
|
|
@ -363,6 +363,20 @@ handleIpc("folio:renameBook", (_e, p: string, rawTitle: string) => {
|
||||||
return { ok: true, title };
|
return { ok: true, title };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hide/unhide a recents entry. List-only: the book folder on disk is never
|
||||||
|
// touched, and opening the book again un-hides it (addRecent clears the flag).
|
||||||
|
handleIpc("folio:hideRecent", (_e, p: string) => {
|
||||||
|
hideRecent(p);
|
||||||
|
refreshMenu();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
handleIpc("folio:unhideRecent", (_e, p: string) => {
|
||||||
|
unhideRecent(p);
|
||||||
|
refreshMenu();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
handleIpc("folio:closeBook", () => {
|
handleIpc("folio:closeBook", () => {
|
||||||
bookPath = null;
|
bookPath = null;
|
||||||
mainWindow?.setTitle("Folio");
|
mainWindow?.setTitle("Folio");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { app, Menu, BrowserWindow } from "electron";
|
import { app, Menu, BrowserWindow } from "electron";
|
||||||
import { listRecents } from "./recents.js";
|
import { listVisibleRecents } from "./recents.js";
|
||||||
import type { Recent } from "./recents.js";
|
import type { Recent } from "./recents.js";
|
||||||
|
|
||||||
type MenuItem = Electron.MenuItemConstructorOptions;
|
type MenuItem = Electron.MenuItemConstructorOptions;
|
||||||
|
|
@ -14,7 +14,7 @@ export function buildMenu(
|
||||||
onRequestOpen: () => void
|
onRequestOpen: () => void
|
||||||
) {
|
) {
|
||||||
const isMac = process.platform === "darwin";
|
const isMac = process.platform === "darwin";
|
||||||
const recents = listRecents();
|
const recents = listVisibleRecents();
|
||||||
|
|
||||||
const recentItems: MenuItem[] = recents.length
|
const recentItems: MenuItem[] = recents.length
|
||||||
? recents.map((r: Recent) => {
|
? recents.map((r: Recent) => {
|
||||||
|
|
@ -63,11 +63,11 @@ export function buildMenu(
|
||||||
accelerator: "CmdOrCtrl+R",
|
accelerator: "CmdOrCtrl+R",
|
||||||
// Always usable: falls back to the most recent valid book when none
|
// Always usable: falls back to the most recent valid book when none
|
||||||
// is currently open, so the item never looks clickable yet do nothing.
|
// is currently open, so the item never looks clickable yet do nothing.
|
||||||
enabled: !!getState().bookPath || listRecents().some((r) => !r.missing),
|
enabled: !!getState().bookPath || listVisibleRecents().some((r) => !r.missing),
|
||||||
click: () => {
|
click: () => {
|
||||||
const target =
|
const target =
|
||||||
getState().bookPath ??
|
getState().bookPath ??
|
||||||
listRecents().find((r) => !r.missing)?.path ??
|
listVisibleRecents().find((r) => !r.missing)?.path ??
|
||||||
null;
|
null;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
getWindow()?.webContents.send("folio:reveal-requested", target);
|
getWindow()?.webContents.send("folio:reveal-requested", target);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ contextBridge.exposeInMainWorld("folio", {
|
||||||
newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name),
|
newBookNamed: (name: string) => ipcRenderer.invoke("folio:newBookNamed", name),
|
||||||
deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p),
|
deleteBook: (p: string) => ipcRenderer.invoke("folio:deleteBook", p),
|
||||||
renameBook: (p: string, title: string) => ipcRenderer.invoke("folio:renameBook", p, title),
|
renameBook: (p: string, title: string) => ipcRenderer.invoke("folio:renameBook", p, title),
|
||||||
|
hideRecent: (p: string) => ipcRenderer.invoke("folio:hideRecent", p),
|
||||||
|
unhideRecent: (p: string) => ipcRenderer.invoke("folio:unhideRecent", p),
|
||||||
restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p),
|
restoreBook: (p: string) => ipcRenderer.invoke("folio:restoreBook", p),
|
||||||
openBook: () => ipcRenderer.invoke("folio:openBook"),
|
openBook: () => ipcRenderer.invoke("folio:openBook"),
|
||||||
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),
|
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ export interface Recent {
|
||||||
path: string;
|
path: string;
|
||||||
title: string;
|
title: string;
|
||||||
lastOpened: string;
|
lastOpened: string;
|
||||||
|
// Hidden entries stay in the recents file (and the book on disk is
|
||||||
|
// untouched) but are omitted from the visible recents list and menu.
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookResult {
|
export interface BookResult {
|
||||||
|
|
@ -121,8 +124,10 @@ export function listRecents(filePath: string): Recent[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addRecent(filePath: string, recent: Recent): Recent[] {
|
export function addRecent(filePath: string, recent: Recent): Recent[] {
|
||||||
|
// Opening a book always surfaces it: a previously hidden entry is
|
||||||
|
// un-hidden here, so re-opening is the natural way back.
|
||||||
const recents = listRecents(filePath).filter((r) => r.path !== recent.path);
|
const recents = listRecents(filePath).filter((r) => r.path !== recent.path);
|
||||||
recents.unshift(recent);
|
recents.unshift({ ...recent, hidden: false });
|
||||||
writeJsonFile(filePath, recents);
|
writeJsonFile(filePath, recents);
|
||||||
return recents;
|
return recents;
|
||||||
}
|
}
|
||||||
|
|
@ -140,3 +145,22 @@ export function renameRecent(filePath: string, recentPath: string, title: string
|
||||||
writeJsonFile(filePath, recents);
|
writeJsonFile(filePath, recents);
|
||||||
return recents;
|
return recents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide/unhide are list-only operations: the entry stays in the recents file
|
||||||
|
// and the book directory on disk is never touched. Hiding a path that is not
|
||||||
|
// in the list is a no-op (there is nothing to hide).
|
||||||
|
export function hideRecent(filePath: string, recentPath: string): Recent[] {
|
||||||
|
const recents = listRecents(filePath).map((r) =>
|
||||||
|
r.path === recentPath ? { ...r, hidden: true } : r
|
||||||
|
);
|
||||||
|
writeJsonFile(filePath, recents);
|
||||||
|
return recents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unhideRecent(filePath: string, recentPath: string): Recent[] {
|
||||||
|
const recents = listRecents(filePath).map((r) =>
|
||||||
|
r.path === recentPath ? { ...r, hidden: false } : r
|
||||||
|
);
|
||||||
|
writeJsonFile(filePath, recents);
|
||||||
|
return recents;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import {
|
||||||
addRecent as _addRecent,
|
addRecent as _addRecent,
|
||||||
removeRecent as _removeRecent,
|
removeRecent as _removeRecent,
|
||||||
renameRecent as _renameRecent,
|
renameRecent as _renameRecent,
|
||||||
|
hideRecent as _hideRecent,
|
||||||
|
unhideRecent as _unhideRecent,
|
||||||
} from "./project.js";
|
} from "./project.js";
|
||||||
import { readJsonArray, writeJsonFile } from "./json-store.js";
|
import { readJsonArray, writeJsonFile } from "./json-store.js";
|
||||||
import type { Recent } from "./project.js";
|
import type { Recent } from "./project.js";
|
||||||
|
|
@ -60,6 +62,8 @@ 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.
|
||||||
|
// Hidden entries are returned too (flagged) — the UI decides visibility so
|
||||||
|
// it can offer a "Hidden (n)" disclosure to bring them back.
|
||||||
// Dedupe by PATH: keying on title used to hide genuinely distinct books
|
// Dedupe by PATH: keying on title used to hide genuinely distinct books
|
||||||
// that happened to share a display name ("Untitled"), making one of them
|
// that happened to share a display name ("Untitled"), making one of them
|
||||||
// unreachable from the recents menu.
|
// unreachable from the recents menu.
|
||||||
|
|
@ -74,6 +78,12 @@ export function listRecents(): (Recent & { missing?: boolean })[] {
|
||||||
return deduped;
|
return deduped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Entries shown in the Open Recent menu and the welcome Recents list:
|
||||||
|
// everything except explicitly hidden ones.
|
||||||
|
export function listVisibleRecents(): (Recent & { missing?: boolean })[] {
|
||||||
|
return listRecents().filter((r) => !r.hidden);
|
||||||
|
}
|
||||||
|
|
||||||
export function addRecent(recent: Recent): Recent[] {
|
export function addRecent(recent: Recent): Recent[] {
|
||||||
return _addRecent(recentsFilePath(), recent);
|
return _addRecent(recentsFilePath(), recent);
|
||||||
}
|
}
|
||||||
|
|
@ -85,3 +95,11 @@ export function removeRecent(recentPath: string): Recent[] {
|
||||||
export function renameRecent(recentPath: string, title: string): Recent[] {
|
export function renameRecent(recentPath: string, title: string): Recent[] {
|
||||||
return _renameRecent(recentsFilePath(), recentPath, title);
|
return _renameRecent(recentsFilePath(), recentPath, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hideRecent(recentPath: string): Recent[] {
|
||||||
|
return _hideRecent(recentsFilePath(), recentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unhideRecent(recentPath: string): Recent[] {
|
||||||
|
return _unhideRecent(recentsFilePath(), recentPath);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ export interface Recent {
|
||||||
path: string;
|
path: string;
|
||||||
title: string;
|
title: string;
|
||||||
lastOpened: string;
|
lastOpened: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
missing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenedBook {
|
export interface OpenedBook {
|
||||||
|
|
@ -39,6 +41,8 @@ export type FolioAPI = {
|
||||||
revealInFolder: (p: string) => Promise<boolean>;
|
revealInFolder: (p: string) => Promise<boolean>;
|
||||||
getRecents: () => Promise<any[]>;
|
getRecents: () => Promise<any[]>;
|
||||||
renameBook: (p: string, title: string) => Promise<any>;
|
renameBook: (p: string, title: string) => Promise<any>;
|
||||||
|
hideRecent: (p: string) => Promise<any>;
|
||||||
|
unhideRecent: (p: string) => Promise<any>;
|
||||||
closeBook: () => Promise<boolean>;
|
closeBook: () => Promise<boolean>;
|
||||||
getSpellcheck: () => Promise<boolean>;
|
getSpellcheck: () => Promise<boolean>;
|
||||||
setSpellcheck: (on: boolean) => Promise<any>;
|
setSpellcheck: (on: boolean) => Promise<any>;
|
||||||
|
|
|
||||||
|
|
@ -185,22 +185,39 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
||||||
try {
|
try {
|
||||||
const recents = await api.getRecents();
|
const recents = await api.getRecents();
|
||||||
recentList.innerHTML = "";
|
recentList.innerHTML = "";
|
||||||
if (!recents.length) {
|
const visible = (recents as (Recent & { missing?: boolean })[]).filter(
|
||||||
|
(r) => !r.hidden
|
||||||
|
);
|
||||||
|
const hidden = (recents as (Recent & { missing?: boolean })[]).filter(
|
||||||
|
(r) => r.hidden
|
||||||
|
);
|
||||||
|
if (!visible.length && !hidden.length) {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.className = "muted";
|
li.className = "muted";
|
||||||
li.textContent = "No recent books.";
|
li.textContent = "No recent books.";
|
||||||
recentList.appendChild(li);
|
recentList.appendChild(li);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const r of recents as (Recent & { missing?: boolean })[]) {
|
for (const r of visible) {
|
||||||
|
recentList.appendChild(recentRow(r, false));
|
||||||
|
}
|
||||||
|
if (hidden.length) {
|
||||||
|
recentList.appendChild(hiddenDisclosure(hidden));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Recents are best-effort; never block the welcome screen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentRow(r: Recent & { missing?: boolean }, isHidden: boolean): HTMLLIElement {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.className = "recent-item";
|
li.className = "recent-item" + (isHidden ? " recent-hidden-entry" : "");
|
||||||
li.tabIndex = 0;
|
li.tabIndex = 0;
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.className = "recent-title";
|
label.className = "recent-title";
|
||||||
label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
|
label.textContent = (r.title || r.path) + (r.missing ? " (missing)" : "");
|
||||||
label.title = r.path;
|
label.title = r.path;
|
||||||
// Single click selects the row (reveals Rename/Delete actions);
|
// Single click selects the row (reveals Rename/Hide/Delete actions);
|
||||||
// double-click or Enter opens the book. Replaces the old always-on
|
// double-click or Enter opens the book. Replaces the old always-on
|
||||||
// "×" button, which deleted on a mis-click.
|
// "×" button, which deleted on a mis-click.
|
||||||
label.addEventListener("click", () => selectRecent(li));
|
label.addEventListener("click", () => selectRecent(li));
|
||||||
|
|
@ -233,6 +250,35 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
||||||
});
|
});
|
||||||
actions.appendChild(ren);
|
actions.appendChild(ren);
|
||||||
}
|
}
|
||||||
|
if (isHidden) {
|
||||||
|
const unhide = document.createElement("button");
|
||||||
|
unhide.className = "recent-action icon-button recent-hide";
|
||||||
|
unhide.type = "button";
|
||||||
|
unhide.title = "Show in recents (book on disk is untouched)";
|
||||||
|
unhide.setAttribute("aria-label", "Unhide book in recents");
|
||||||
|
unhide.textContent = "Unhide";
|
||||||
|
unhide.addEventListener("click", async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await api.unhideRecent(r.path);
|
||||||
|
await renderRecents();
|
||||||
|
});
|
||||||
|
actions.appendChild(unhide);
|
||||||
|
} else {
|
||||||
|
// Hide is list-only — safe by design, so it needs no confirmation and
|
||||||
|
// sits BEFORE the destructive Delete so a mis-click lands on the safe one.
|
||||||
|
const hide = document.createElement("button");
|
||||||
|
hide.className = "recent-action icon-button recent-hide";
|
||||||
|
hide.type = "button";
|
||||||
|
hide.title = "Hide from recents (book on disk is untouched)";
|
||||||
|
hide.setAttribute("aria-label", "Hide from recents");
|
||||||
|
hide.textContent = "Hide";
|
||||||
|
hide.addEventListener("click", async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await api.hideRecent(r.path);
|
||||||
|
await renderRecents();
|
||||||
|
});
|
||||||
|
actions.appendChild(hide);
|
||||||
|
}
|
||||||
const del = document.createElement("button");
|
const del = document.createElement("button");
|
||||||
del.className = "recent-action icon-button recent-delete";
|
del.className = "recent-action icon-button recent-delete";
|
||||||
del.type = "button";
|
del.type = "button";
|
||||||
|
|
@ -263,11 +309,29 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
||||||
actions.appendChild(del);
|
actions.appendChild(del);
|
||||||
li.appendChild(label);
|
li.appendChild(label);
|
||||||
li.appendChild(actions);
|
li.appendChild(actions);
|
||||||
recentList.appendChild(li);
|
return li;
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Recents are best-effort; never block the welcome screen.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collapsible "Hidden (n)" row: click to expand the hidden entries inline,
|
||||||
|
// each offering Unhide. Hidden books stay reachable without polluting the
|
||||||
|
// main list.
|
||||||
|
function hiddenDisclosure(hidden: (Recent & { missing?: boolean })[]): HTMLLIElement {
|
||||||
|
const wrap = document.createElement("li");
|
||||||
|
wrap.className = "recent-hidden-group";
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "recent-hidden-toggle";
|
||||||
|
toggle.textContent = `Hidden (${hidden.length})`;
|
||||||
|
const sub = document.createElement("ul");
|
||||||
|
sub.className = "recent-list recent-hidden-items hidden";
|
||||||
|
for (const r of hidden) sub.appendChild(recentRow(r, true));
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
sub.classList.toggle("hidden");
|
||||||
|
toggle.classList.toggle("open");
|
||||||
|
});
|
||||||
|
wrap.appendChild(toggle);
|
||||||
|
wrap.appendChild(sub);
|
||||||
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// At most one recents row is selected at a time; selection reveals the
|
// At most one recents row is selected at a time; selection reveals the
|
||||||
|
|
|
||||||
|
|
@ -665,6 +665,50 @@ html, body {
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide is list-only, so it reads quieter than the destructive Delete. */
|
||||||
|
.recent-item .recent-hide:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Hidden (n)" disclosure at the bottom of the recents list. */
|
||||||
|
.recent-hidden-group {
|
||||||
|
list-style: none;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-hidden-toggle {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-hidden-toggle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-hidden-toggle::before {
|
||||||
|
content: "▸ ";
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-hidden-toggle.open::before {
|
||||||
|
content: "▾ ";
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-hidden-items {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-hidden-items .recent-title {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ const {
|
||||||
hasBook,
|
hasBook,
|
||||||
listRecents,
|
listRecents,
|
||||||
addRecent,
|
addRecent,
|
||||||
|
hideRecent,
|
||||||
|
unhideRecent,
|
||||||
makeChapterId,
|
makeChapterId,
|
||||||
chapterFileName,
|
chapterFileName,
|
||||||
} = mod;
|
} = mod;
|
||||||
|
|
@ -65,6 +67,19 @@ ok(r2.length === 2 && r2[0].path === "/other", "second recent added to top");
|
||||||
const r3 = addRecent(rf, { path: dir, title: "My Novel", lastOpened: "2026-03-03T00:00:00Z" });
|
const r3 = addRecent(rf, { path: dir, title: "My Novel", lastOpened: "2026-03-03T00:00:00Z" });
|
||||||
ok(r3.length === 2 && r3[0].path === dir, "duplicate path deduped + moved to top");
|
ok(r3.length === 2 && r3[0].path === dir, "duplicate path deduped + moved to top");
|
||||||
|
|
||||||
|
// Hide/unhide: list-only — entry stays in the file, disk untouched.
|
||||||
|
const h1 = hideRecent(rf, "/other");
|
||||||
|
ok(h1.find((r) => r.path === "/other")?.hidden === true, "hide marks entry hidden");
|
||||||
|
ok(h1.length === 2, "hide keeps the entry in the file");
|
||||||
|
ok(fs.existsSync(path.join(dir, "folio.json")), "hide leaves book on disk intact");
|
||||||
|
const h2 = addRecent(rf, { path: "/other", title: "Other", lastOpened: "2026-04-04T00:00:00Z" });
|
||||||
|
ok(h2.find((r) => r.path === "/other")?.hidden === false, "re-opening un-hides");
|
||||||
|
const h3 = hideRecent(rf, "/other");
|
||||||
|
const h4 = unhideRecent(rf, "/other");
|
||||||
|
ok(h4.find((r) => r.path === "/other")?.hidden === false, "unhide clears the flag");
|
||||||
|
ok(h3.length === 2 && h4.length === 2, "hide/unhide never drop entries");
|
||||||
|
ok(hideRecent(rf, "/nowhere").length === 2, "hide of unknown path is a no-op");
|
||||||
|
|
||||||
const id = makeChapterId("Chapter One");
|
const id = makeChapterId("Chapter One");
|
||||||
ok(id.startsWith("chapter-one-"), "chapter id slugified: " + id);
|
ok(id.startsWith("chapter-one-"), "chapter id slugified: " + id);
|
||||||
ok(chapterFileName(id) === `${id}.md`, "filename is <id>.md");
|
ok(chapterFileName(id) === `${id}.md`, "filename is <id>.md");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue