Compare commits
No commits in common. "main" and "v0.1.2" have entirely different histories.
18 changed files with 175 additions and 753 deletions
30
START-HERE.md
Normal file
30
START-HERE.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
## RAM Verification
|
||||||
|
|
||||||
|
### System Memory Configuration
|
||||||
|
|
||||||
|
Before proceeding, verify that the system has sufficient memory for running Hermes and all intended plugins:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check total available physical memory (KB)
|
||||||
|
free -m | head -10
|
||||||
|
|
||||||
|
# Recommended minimum specifications:
|
||||||
|
# ≥ 8GB recommended minimum for stable operation
|
||||||
|
# Optimal runtime requires free RAM buffer space (~25% of system memory should remain unused)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memory Usage Monitoring
|
||||||
|
|
||||||
|
Monitor real-time memory usage during plugin initialization and startup operations:
|
||||||
|
|
||||||
|
| Timepoint | Check Point | Expected Status |
|
||||||
|
|-----------|-------------|-----------------|
|
||||||
|
| T+0s | Plugin module detection complete | Ready to instantiate RAM modules |
|
||||||
|
| T+124.538ms (± 4%) | Module instantiation & validation complete | `Status:` **ready** with all RAM modules operational; no pending initialisations or incomplete state retrievals recorded in plugin metadata |
|
||||||
|
|
||||||
|
### Verification Results Summary
|
||||||
|
|
||||||
|
Upon confirming adequate system memory allocation, the verification procedure will produce:
|
||||||
|
- All required plugins successfully loaded into active runtime environment
|
||||||
|
- Memory management APIs functioning as expected for subsequent operations
|
||||||
|
- No resource conflicts detected during initialization phase
|
||||||
0
Things to have changed for the App
Normal file
0
Things to have changed for the App
Normal file
|
|
@ -21,11 +21,6 @@ linux:
|
||||||
target:
|
target:
|
||||||
- AppImage
|
- AppImage
|
||||||
- deb
|
- deb
|
||||||
# tar.gz is what the in-app updater installs (pickAsset looks for a
|
|
||||||
# linux x86_64/amd64 tarball); arch must be x64 so the asset name ends
|
|
||||||
# in -linux-x86_64 like every published release.
|
|
||||||
- target: tar.gz
|
|
||||||
arch: x64
|
|
||||||
|
|
||||||
# OS file associations: double-click / "Open with Folio" on Markdown and
|
# OS file associations: double-click / "Open with Folio" on Markdown and
|
||||||
# plain-text files imports them as books (see folio:openPathOrImport and
|
# plain-text files imports them as books (see folio:openPathOrImport and
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "folio",
|
"name": "folio",
|
||||||
"version": "0.1.17",
|
"version": "0.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "folio",
|
"name": "folio",
|
||||||
"version": "0.1.17",
|
"version": "0.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/core": "^2.27.2",
|
"@tiptap/core": "^2.27.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "folio",
|
"name": "folio",
|
||||||
"version": "0.1.17",
|
"version": "0.1.2",
|
||||||
"description": "A simple, local, open-source desktop writing app.",
|
"description": "A simple, local, open-source desktop writing app.",
|
||||||
"productName": "Folio",
|
"productName": "Folio",
|
||||||
"author": "Folio Contributors",
|
"author": "Folio Contributors",
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
|
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
|
||||||
"start": "npm run build && npm run electron",
|
"start": "npm run build && npm run electron",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"prepackage": "npm run build",
|
|
||||||
"test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs && node tests/run-update-import-test.mjs && node tests/run-update-installer-test.mjs && node tests/run-snapshots-test.mjs && node tests/run-search-test.mjs",
|
"test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs && node tests/run-update-import-test.mjs && node tests/run-update-installer-test.mjs && node tests/run-snapshots-test.mjs && node tests/run-search-test.mjs",
|
||||||
"package": "electron-builder"
|
"package": "electron-builder"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
saveMeta,
|
saveMeta,
|
||||||
Recent,
|
Recent,
|
||||||
} from "./project.js";
|
} from "./project.js";
|
||||||
import { listRecents, addRecent, removeRecent, renameRecent, hideRecent, unhideRecent, migrateLegacyRecents } from "./recents.js";
|
import { listRecents, addRecent, removeRecent, renameRecent, migrateLegacyRecents } from "./recents.js";
|
||||||
import { buildMenu } from "./menu.js";
|
import { buildMenu } from "./menu.js";
|
||||||
import {
|
import {
|
||||||
createChapter,
|
createChapter,
|
||||||
|
|
@ -56,8 +56,6 @@ import {
|
||||||
UPDATE_REPO,
|
UPDATE_REPO,
|
||||||
buildInstallerScript,
|
buildInstallerScript,
|
||||||
isTrustedDownloadUrl,
|
isTrustedDownloadUrl,
|
||||||
looksTruncated,
|
|
||||||
verifyArchiveGzip,
|
|
||||||
isNewer,
|
isNewer,
|
||||||
parseRelease,
|
parseRelease,
|
||||||
pickAsset,
|
pickAsset,
|
||||||
|
|
@ -246,30 +244,13 @@ async function openBookAt(p: string): Promise<BookResult | ErrResult> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new book as a dedicated folder <name>/ inside the user's chosen
|
// Create a new book as a dedicated folder <name>/ inside the user's Documents
|
||||||
// storage folder (Settings → Folder), falling back to Documents when none is
|
// directory, then open it. The folder name is the sanitized book title; if a
|
||||||
// set or the stored one no longer exists, then open it. The folder name is
|
// folder with that name already exists we append a numeric suffix.
|
||||||
// the sanitized book title; if a folder with that name already exists we
|
|
||||||
// append a numeric suffix.
|
|
||||||
function getBooksDir(): string {
|
|
||||||
// One storage folder drives everything (snapshots, recovery, new books).
|
|
||||||
// snapshotLocation is the one the Settings modal writes; backupLocation is
|
|
||||||
// the older key — honor both so an existing choice keeps working.
|
|
||||||
for (const key of ["snapshotLocation", "backupLocation"]) {
|
|
||||||
const v = String(getSetting<string>(key, "") || "").trim();
|
|
||||||
try {
|
|
||||||
if (v && fs.statSync(v).isDirectory()) return v;
|
|
||||||
} catch {
|
|
||||||
// stale or unreadable path — fall through to the next candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return app.getPath("documents");
|
|
||||||
}
|
|
||||||
|
|
||||||
handleIpc("folio:newBookNamed", async (_e, name: string) => {
|
handleIpc("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 = getBooksDir();
|
const base = app.getPath("documents");
|
||||||
const folder = sanitizeBookFolderName(title) || "Untitled";
|
const folder = sanitizeBookFolderName(title) || "Untitled";
|
||||||
let target = path.join(base, folder);
|
let target = path.join(base, folder);
|
||||||
let n = 2;
|
let n = 2;
|
||||||
|
|
@ -357,70 +338,6 @@ handleIpc("folio:pickBackupFolder", async () => {
|
||||||
|
|
||||||
handleIpc("folio:getRecents", (): Recent[] => listRecents());
|
handleIpc("folio:getRecents", (): Recent[] => listRecents());
|
||||||
|
|
||||||
// ---- recents right-click menu ----
|
|
||||||
// The renderer claims a right-click on a recents row synchronously
|
|
||||||
// (ipcRenderer.sendSync) before the main-process context-menu event fires,
|
|
||||||
// so the generic spelling/edit popup is skipped for that target and a
|
|
||||||
// dedicated book menu is shown instead. Menu actions are sent back to the
|
|
||||||
// renderer (folio:recents-menu) and executed by the same handlers the row
|
|
||||||
// buttons use — one code path, consistent behavior.
|
|
||||||
interface RecentsMenuEntry {
|
|
||||||
path: string;
|
|
||||||
title: string;
|
|
||||||
hidden?: boolean;
|
|
||||||
missing?: boolean;
|
|
||||||
}
|
|
||||||
let recentsMenuClaimedAt = 0;
|
|
||||||
|
|
||||||
ipcMain.on(
|
|
||||||
"folio:recents-contextmenu-sync",
|
|
||||||
(event, entry: RecentsMenuEntry, vx: number, vy: number) => {
|
|
||||||
if (!entry || typeof entry.path !== "string") {
|
|
||||||
event.returnValue = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const win = getWindow();
|
|
||||||
if (!win) {
|
|
||||||
event.returnValue = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
recentsMenuClaimedAt = Date.now();
|
|
||||||
// Convert viewport coords to screen coords for the popup.
|
|
||||||
const [ox, oy] = win.getContentBounds
|
|
||||||
? [win.getContentBounds().x, win.getContentBounds().y]
|
|
||||||
: [0, 0];
|
|
||||||
const x = Number.isFinite(vx) ? ox + Math.round(vx) : undefined;
|
|
||||||
const y = Number.isFinite(vy) ? oy + Math.round(vy) : undefined;
|
|
||||||
const exists = hasBook(entry.path);
|
|
||||||
const send = (action: string) =>
|
|
||||||
win.webContents.send("folio:recents-menu", { action, entry });
|
|
||||||
const items: Electron.MenuItemConstructorOptions[] = [];
|
|
||||||
if (exists) {
|
|
||||||
items.push({ label: "Open", click: () => send("open") });
|
|
||||||
items.push({ label: "Rename…", click: () => send("rename") });
|
|
||||||
items.push({ label: "Show in Folder", click: () => send("reveal") });
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
entry.hidden
|
|
||||||
? { label: "Unhide in Recents", click: () => send("unhide") }
|
|
||||||
: {
|
|
||||||
label: "Hide from Recents",
|
|
||||||
toolTip:
|
|
||||||
"Removes the entry from the list only — the book on disk is untouched",
|
|
||||||
click: () => send("hide"),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
items.push({ type: "separator" });
|
|
||||||
items.push({
|
|
||||||
label: exists ? "Delete Book (to Trash)…" : "Remove from List",
|
|
||||||
click: () => send(exists ? "delete" : "remove"),
|
|
||||||
});
|
|
||||||
Menu.buildFromTemplate(items).popup({ window: win, x, y });
|
|
||||||
event.returnValue = true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rename a book's display title (folio.json + matching recents entry). The
|
// Rename a book's display title (folio.json + matching recents entry). The
|
||||||
// folder on disk keeps its name: renaming the directory would invalidate
|
// folder on disk keeps its name: renaming the directory would invalidate
|
||||||
// every other recents/menu path that points at it, so the title is the only
|
// every other recents/menu path that points at it, so the title is the only
|
||||||
|
|
@ -444,20 +361,6 @@ 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");
|
||||||
|
|
@ -599,36 +502,14 @@ function currentBookTitle(): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GTK save dialogs started with a bare filename (no directory) re-synthesize
|
|
||||||
// the name from the last-browsed folder when the user navigates, producing
|
|
||||||
// names like "Some Folder/.odt". Always hand them an absolute path. The last
|
|
||||||
// export folder is remembered so exports reopen where the user left them.
|
|
||||||
function exportDefaultPath(filename: string): string {
|
|
||||||
const last = getSetting<string>("lastExportDir", "");
|
|
||||||
const dir =
|
|
||||||
last && fs.existsSync(last) && fs.statSync(last).isDirectory()
|
|
||||||
? last
|
|
||||||
: getBooksDir();
|
|
||||||
return path.join(dir, filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rememberExportDir(filePath: string): void {
|
|
||||||
try {
|
|
||||||
setSetting("lastExportDir", path.dirname(filePath));
|
|
||||||
} catch {
|
|
||||||
// best-effort only
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleIpc("folio:exportMarkdown", async () => {
|
handleIpc("folio:exportMarkdown", async () => {
|
||||||
const bp = requireBook();
|
const bp = requireBook();
|
||||||
const { canceled, filePath } = await showSaveDialog({
|
const { canceled, filePath } = await showSaveDialog({
|
||||||
title: "Export combined Markdown",
|
title: "Export combined Markdown",
|
||||||
defaultPath: exportDefaultPath(`${currentBookTitle()}.md`),
|
defaultPath: `${currentBookTitle()}.md`,
|
||||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||||
});
|
});
|
||||||
if (canceled || !filePath) return { canceled: true } as const;
|
if (canceled || !filePath) return { canceled: true } as const;
|
||||||
rememberExportDir(filePath);
|
|
||||||
fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8");
|
fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8");
|
||||||
return { ok: true, filePath };
|
return { ok: true, filePath };
|
||||||
});
|
});
|
||||||
|
|
@ -637,11 +518,10 @@ handleIpc("folio:exportZip", async () => {
|
||||||
const bp = requireBook();
|
const bp = requireBook();
|
||||||
const { canceled, filePath } = await showSaveDialog({
|
const { canceled, filePath } = await showSaveDialog({
|
||||||
title: "Export book bundle (ZIP)",
|
title: "Export book bundle (ZIP)",
|
||||||
defaultPath: exportDefaultPath(`${currentBookTitle()}.zip`),
|
defaultPath: `${currentBookTitle()}.zip`,
|
||||||
filters: [{ name: "ZIP Archive", extensions: ["zip"] }],
|
filters: [{ name: "ZIP Archive", extensions: ["zip"] }],
|
||||||
});
|
});
|
||||||
if (canceled || !filePath) return { canceled: true } as const;
|
if (canceled || !filePath) return { canceled: true } as const;
|
||||||
rememberExportDir(filePath);
|
|
||||||
const buf = await buildZip(bp);
|
const buf = await buildZip(bp);
|
||||||
fs.writeFileSync(filePath, buf);
|
fs.writeFileSync(filePath, buf);
|
||||||
return { ok: true, filePath };
|
return { ok: true, filePath };
|
||||||
|
|
@ -669,12 +549,11 @@ requireBook();
|
||||||
} else {
|
} else {
|
||||||
const { canceled, filePath: chosen } = await showSaveDialog({
|
const { canceled, filePath: chosen } = await showSaveDialog({
|
||||||
title: "Export as PDF",
|
title: "Export as PDF",
|
||||||
defaultPath: exportDefaultPath(`${base}${suffix}.pdf`),
|
defaultPath: `${base}${suffix}.pdf`,
|
||||||
filters: [{ name: "PDF Document", extensions: ["pdf"] }],
|
filters: [{ name: "PDF Document", extensions: ["pdf"] }],
|
||||||
});
|
});
|
||||||
if (canceled || !chosen) return { canceled: true } as const;
|
if (canceled || !chosen) return { canceled: true } as const;
|
||||||
filePath = chosen.endsWith(".pdf") ? chosen : `${chosen}.pdf`;
|
filePath = chosen.endsWith(".pdf") ? chosen : `${chosen}.pdf`;
|
||||||
rememberExportDir(filePath);
|
|
||||||
}
|
}
|
||||||
const win = getWindow();
|
const win = getWindow();
|
||||||
if (!win) return { error: "No window is open." };
|
if (!win) return { error: "No window is open." };
|
||||||
|
|
@ -711,7 +590,7 @@ async function exportOffice(format: "docx" | "odt", chapterIds?: string[] | null
|
||||||
} else {
|
} else {
|
||||||
const { canceled, filePath: chosen } = await showSaveDialog({
|
const { canceled, filePath: chosen } = await showSaveDialog({
|
||||||
title: `Export${selected ? " Selected Chapters" : " Full Book"} as ${format.toUpperCase()}`,
|
title: `Export${selected ? " Selected Chapters" : " Full Book"} as ${format.toUpperCase()}`,
|
||||||
defaultPath: exportDefaultPath(`${base}${suffix}.${ext}`),
|
defaultPath: `${base}${suffix}.${ext}`,
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
name: format === "docx" ? "Word Document" : "OpenDocument Text",
|
name: format === "docx" ? "Word Document" : "OpenDocument Text",
|
||||||
|
|
@ -721,7 +600,6 @@ async function exportOffice(format: "docx" | "odt", chapterIds?: string[] | null
|
||||||
});
|
});
|
||||||
if (canceled || !chosen) return { canceled: true } as const;
|
if (canceled || !chosen) return { canceled: true } as const;
|
||||||
filePath = chosen.endsWith(`.${ext}`) ? chosen : `${chosen}.${ext}`;
|
filePath = chosen.endsWith(`.${ext}`) ? chosen : `${chosen}.${ext}`;
|
||||||
rememberExportDir(filePath);
|
|
||||||
}
|
}
|
||||||
const meta = loadBook(bp);
|
const meta = loadBook(bp);
|
||||||
const chapters = selected
|
const chapters = selected
|
||||||
|
|
@ -753,11 +631,10 @@ handleIpc("folio:saveChapterAs", async (_e, id: string) => {
|
||||||
if (!entry) return { error: "Chapter not found" };
|
if (!entry) return { error: "Chapter not found" };
|
||||||
const { canceled, filePath } = await showSaveDialog({
|
const { canceled, filePath } = await showSaveDialog({
|
||||||
title: "Save Chapter As",
|
title: "Save Chapter As",
|
||||||
defaultPath: exportDefaultPath(`${entry.title}.md`),
|
defaultPath: `${entry.title}.md`,
|
||||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||||
});
|
});
|
||||||
if (canceled || !filePath) return { canceled: true } as const;
|
if (canceled || !filePath) return { canceled: true } as const;
|
||||||
rememberExportDir(filePath);
|
|
||||||
const content = getChapterContent(bp, id);
|
const content = getChapterContent(bp, id);
|
||||||
fs.writeFileSync(filePath, content, "utf-8");
|
fs.writeFileSync(filePath, content, "utf-8");
|
||||||
return { ok: true, filePath };
|
return { ok: true, filePath };
|
||||||
|
|
@ -876,12 +753,6 @@ function wireContextMenu(win: BrowserWindow) {
|
||||||
win.webContents.on("context-menu", (_e, params) => {
|
win.webContents.on("context-menu", (_e, params) => {
|
||||||
const target = getWindow();
|
const target = getWindow();
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
// A recents row claimed this right-click (see the
|
|
||||||
// folio:recents-contextmenu-sync handler): its dedicated book menu was
|
|
||||||
// already popped up, so skip the generic spelling/edit popup. The claim
|
|
||||||
// is only honored within a short window so it can never suppress later
|
|
||||||
// right-clicks elsewhere in the app.
|
|
||||||
if (Date.now() - recentsMenuClaimedAt < 250) return;
|
|
||||||
const items: Electron.MenuItemConstructorOptions[] = [];
|
const items: Electron.MenuItemConstructorOptions[] = [];
|
||||||
if (params.misspelledWord) {
|
if (params.misspelledWord) {
|
||||||
const sugs = (params.dictionarySuggestions || []).slice(0, 5);
|
const sugs = (params.dictionarySuggestions || []).slice(0, 5);
|
||||||
|
|
@ -989,7 +860,6 @@ function snapshotSettingsSnapshot() {
|
||||||
keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200),
|
keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200),
|
||||||
location,
|
location,
|
||||||
resolvedRoot: resolved,
|
resolvedRoot: resolved,
|
||||||
booksDir: getBooksDir(),
|
|
||||||
snapshotCount: snaps.length,
|
snapshotCount: snaps.length,
|
||||||
lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null,
|
lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null,
|
||||||
};
|
};
|
||||||
|
|
@ -1026,7 +896,7 @@ handleIpc("folio:setSnapshotSettings", (_e, patch: unknown) => {
|
||||||
handleIpc("folio:pickSnapshotFolder", async () => {
|
handleIpc("folio:pickSnapshotFolder", async () => {
|
||||||
const { canceled, filePaths } = await showOpenDialog({
|
const { canceled, filePaths } = await showOpenDialog({
|
||||||
properties: ["openDirectory"],
|
properties: ["openDirectory"],
|
||||||
title: "Choose storage folder (new books + snapshots)",
|
title: "Choose snapshot folder",
|
||||||
});
|
});
|
||||||
return { canceled, filePaths };
|
return { canceled, filePaths };
|
||||||
});
|
});
|
||||||
|
|
@ -1107,14 +977,14 @@ handleIpc("folio:importMarkdown", async () => {
|
||||||
filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }],
|
filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }],
|
||||||
});
|
});
|
||||||
if (canceled || !filePaths.length) return { canceled: true } as const;
|
if (canceled || !filePaths.length) return { canceled: true } as const;
|
||||||
return importMarkdownFiles(filePaths, getBooksDir());
|
return importMarkdownFiles(filePaths, app.getPath("documents"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Open a path that may be either a Folio book directory or a Markdown file
|
// Open a path that may be either a Folio book directory or a Markdown file
|
||||||
// (the desktop launcher can be handed either via file associations).
|
// (the desktop launcher can be handed either via file associations).
|
||||||
handleIpc("folio:openPathOrImport", async (_e, p: string) => {
|
handleIpc("folio:openPathOrImport", async (_e, p: string) => {
|
||||||
if (p && isImportableMarkdown(p)) {
|
if (p && isImportableMarkdown(p)) {
|
||||||
const results = importMarkdownFiles([p], getBooksDir());
|
const results = importMarkdownFiles([p], app.getPath("documents"));
|
||||||
const r = results[0];
|
const r = results[0];
|
||||||
if (!r.ok) return { error: r.error };
|
if (!r.ok) return { error: r.error };
|
||||||
return openBookAt(r.bookPath as string);
|
return openBookAt(r.bookPath as string);
|
||||||
|
|
@ -1220,81 +1090,17 @@ handleIpc("folio:performUpdate", async () => {
|
||||||
app.getPath("temp"),
|
app.getPath("temp"),
|
||||||
`folio-update-${Date.now()}.tar.gz`
|
`folio-update-${Date.now()}.tar.gz`
|
||||||
);
|
);
|
||||||
// Downloads go through curl, not Chromium's network stack: on flaky Wi-Fi
|
|
||||||
// the Electron net stack restarts requests mid-stream, producing a
|
|
||||||
// right-sized but corrupt gzip ("100% three times, then failed"). curl
|
|
||||||
// resumes with HTTP Range (-C -) so a dropped connection continues where
|
|
||||||
// it left off instead of restarting, and retries internally. We still
|
|
||||||
// verify the exact byte count and gunzip the whole file before handoff.
|
|
||||||
const total = Number(asset.size) || 0;
|
|
||||||
let lastError = "";
|
|
||||||
let downloaded = false;
|
|
||||||
const { spawn } = await import("child_process");
|
|
||||||
const haveCurl = await new Promise<boolean>((resolve) => {
|
|
||||||
const p = spawn("curl", ["--version"], { stdio: "ignore" });
|
|
||||||
p.on("error", () => resolve(false));
|
|
||||||
p.on("close", (code) => resolve(code === 0));
|
|
||||||
});
|
|
||||||
if (haveCurl) {
|
|
||||||
for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
|
|
||||||
const code = await new Promise<number>((resolve) => {
|
|
||||||
const c = spawn(
|
|
||||||
"curl",
|
|
||||||
[
|
|
||||||
"-fsSL",
|
|
||||||
"-C", "-", // resume a partial file
|
|
||||||
"--max-time", "900",
|
|
||||||
"--retry", "5", "--retry-delay", "2", "--retry-all-errors",
|
|
||||||
"--connect-timeout", "15",
|
|
||||||
"-o", tarball,
|
|
||||||
asset.browser_download_url,
|
|
||||||
],
|
|
||||||
{ stdio: "ignore" }
|
|
||||||
);
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
try {
|
|
||||||
getWindow()?.webContents.send("folio:update-progress", {
|
|
||||||
received: fs.statSync(tarball).size,
|
|
||||||
total,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
/* file not created yet */
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
c.on("error", () => { clearInterval(timer); resolve(-1); });
|
|
||||||
c.on("close", (rc) => { clearInterval(timer); resolve(rc ?? -1); });
|
|
||||||
});
|
|
||||||
if (code !== 0) {
|
|
||||||
lastError = `Download failed (curl exit ${code}).`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const size = fs.existsSync(tarball) ? fs.statSync(tarball).size : 0;
|
|
||||||
if (looksTruncated(size, total)) {
|
|
||||||
lastError = `Download truncated (${size}/${total} bytes).`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!(await verifyArchiveGzip(tarball))) {
|
|
||||||
lastError = "Downloaded archive is corrupt (gzip stream incomplete).";
|
|
||||||
fs.rmSync(tarball, { force: true }); // corrupted resume file must not poison the next attempt
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
downloaded = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No curl on PATH: fall back to the in-process fetch pipeline.
|
|
||||||
const { Readable } = await import("stream");
|
|
||||||
const { createWriteStream } = await import("fs");
|
|
||||||
const { pipeline } = await import("stream/promises");
|
|
||||||
for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(asset.browser_download_url, {
|
const res = await fetch(asset.browser_download_url, {
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
signal: AbortSignal.timeout(10 * 60 * 1000),
|
signal: AbortSignal.timeout(10 * 60 * 1000),
|
||||||
});
|
});
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
lastError = `Download failed (HTTP ${res.status}).`;
|
return { error: `Download failed (HTTP ${res.status}).` };
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
const total = Number(res.headers.get("content-length")) || asset.size || 0;
|
||||||
|
const { Readable } = await import("stream");
|
||||||
|
const { createWriteStream } = await import("fs");
|
||||||
|
const { pipeline } = await import("stream/promises");
|
||||||
let received = 0;
|
let received = 0;
|
||||||
const source = Readable.fromWeb(res.body as never);
|
const source = Readable.fromWeb(res.body as never);
|
||||||
source.on("data", (chunk: Buffer) => {
|
source.on("data", (chunk: Buffer) => {
|
||||||
|
|
@ -1302,33 +1108,29 @@ handleIpc("folio:performUpdate", async () => {
|
||||||
getWindow()?.webContents.send("folio:update-progress", { received, total });
|
getWindow()?.webContents.send("folio:update-progress", { received, total });
|
||||||
});
|
});
|
||||||
await pipeline(source, createWriteStream(tarball));
|
await pipeline(source, createWriteStream(tarball));
|
||||||
if (looksTruncated(received, total)) {
|
// Verify the gzip magic (2 bytes) without loading a ~100MB tarball into
|
||||||
lastError = `Download truncated (${received}/${total} bytes).`;
|
// memory. Anything else means a truncated download or an HTML error page.
|
||||||
continue;
|
let head = Buffer.alloc(0);
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(tarball, "r");
|
||||||
|
try {
|
||||||
|
const buf = Buffer.alloc(2);
|
||||||
|
const n = fs.readSync(fd, buf, 0, 2, 0);
|
||||||
|
head = buf.subarray(0, n);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
}
|
}
|
||||||
if (!(await verifyArchiveGzip(tarball))) {
|
} catch {
|
||||||
lastError = "Downloaded archive is corrupt (gzip stream incomplete).";
|
head = Buffer.alloc(0);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
downloaded = true;
|
if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) {
|
||||||
} catch (e) {
|
|
||||||
lastError = `Download failed: ${(e as Error).message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!downloaded) {
|
|
||||||
fs.rmSync(tarball, { force: true });
|
fs.rmSync(tarball, { force: true });
|
||||||
return {
|
return { error: "Downloaded file is not a valid archive; update aborted." };
|
||||||
error: `${lastError || "Download failed."} The update was aborted; your current version is unchanged.`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const script = path.join(
|
const script = path.join(
|
||||||
app.getPath("temp"),
|
app.getPath("temp"),
|
||||||
`folio-update-${process.pid}.sh`
|
`folio-update-${process.pid}.sh`
|
||||||
);
|
);
|
||||||
// Persistent, append-only handoff log: survives the swap and tells us
|
|
||||||
// exactly where a failed update stopped (temp files can vanish).
|
|
||||||
const updateLog = path.join(app.getPath("userData"), "update.log");
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
script,
|
script,
|
||||||
buildInstallerScript({
|
buildInstallerScript({
|
||||||
|
|
@ -1336,48 +1138,15 @@ handleIpc("folio:performUpdate", async () => {
|
||||||
tarball,
|
tarball,
|
||||||
installDir: cfg.installDir,
|
installDir: cfg.installDir,
|
||||||
relaunch: cfg.launcher,
|
relaunch: cfg.launcher,
|
||||||
log: updateLog,
|
|
||||||
}),
|
}),
|
||||||
{ mode: 0o700 }
|
{ mode: 0o700 }
|
||||||
);
|
);
|
||||||
// IMPORTANT: on systemd desktops (Hyprland/uwsm) the app runs inside a
|
const { spawn } = await import("child_process");
|
||||||
// systemd scope cgroup, and a plain detached child stays in that cgroup —
|
|
||||||
// when the app quits, systemd sweeps the scope and kills the swap script
|
|
||||||
// mid-run (root cause of the "update failed" reports: the handoff died
|
|
||||||
// before swapping, and the app was relaunched from the old tree).
|
|
||||||
// systemd-run puts the script in its own transient unit that outlives us;
|
|
||||||
// verified to survive on this machine. Fall back to a detached spawn for
|
|
||||||
// non-systemd sessions.
|
|
||||||
let handedOff = false;
|
|
||||||
try {
|
|
||||||
const { execFileSync } = await import("child_process");
|
|
||||||
execFileSync("systemd-run", [
|
|
||||||
"--user", "--quiet", "--collect",
|
|
||||||
`--description=Folio updater handoff`,
|
|
||||||
// Default KillMode=control-group means the unit's cgroup is swept
|
|
||||||
// when the script exits — that killed the backgrounded relaunch
|
|
||||||
// before Electron got to start (v0.1.15 update: swap OK, app died).
|
|
||||||
// KillMode=process lets the script exit while its child app lives.
|
|
||||||
"--property=KillMode=process",
|
|
||||||
"/bin/sh", script,
|
|
||||||
], { timeout: 10_000, stdio: "ignore" });
|
|
||||||
handedOff = true;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" });
|
const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" });
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
console.error("[folio] updater spawn failed:", err.message);
|
console.error("[folio] updater spawn failed:", err.message);
|
||||||
});
|
});
|
||||||
child.unref();
|
child.unref();
|
||||||
handedOff = true;
|
|
||||||
} catch (e) {
|
|
||||||
void e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!handedOff) {
|
|
||||||
try { fs.appendFileSync(updateLog, new Date().toISOString() + " FAIL: could not start swap script\n"); } catch { /* ignore */ }
|
|
||||||
return { error: "Could not start the installer. Your current version is unchanged." };
|
|
||||||
}
|
|
||||||
// Quit so the swap script can replace the app directory.
|
// Quit so the swap script can replace the app directory.
|
||||||
setTimeout(() => app.quit(), 300);
|
setTimeout(() => app.quit(), 300);
|
||||||
return { ok: true, version: rel.version };
|
return { ok: true, version: rel.version };
|
||||||
|
|
@ -1404,7 +1173,7 @@ function fileArgFrom(argv: string[]): string | null {
|
||||||
|
|
||||||
function openFromArg(p: string): void {
|
function openFromArg(p: string): void {
|
||||||
if (isImportableMarkdown(p)) {
|
if (isImportableMarkdown(p)) {
|
||||||
const results = importMarkdownFiles([p], getBooksDir());
|
const results = importMarkdownFiles([p], app.getPath("documents"));
|
||||||
const r = results[0];
|
const r = results[0];
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
openBookAt(r.bookPath as string);
|
openBookAt(r.bookPath as string);
|
||||||
|
|
@ -1428,8 +1197,4 @@ if (app.requestSingleInstanceLock()) {
|
||||||
}
|
}
|
||||||
if (f) openFromArg(f);
|
if (f) openFromArg(f);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// Lost the lock: another Folio is already running. Without this quit the
|
|
||||||
// second process falls through to whenReady() and opens a duplicate window.
|
|
||||||
app.quit();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { listVisibleRecents } from "./recents.js";
|
import { listRecents } 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 = listVisibleRecents();
|
const recents = listRecents();
|
||||||
|
|
||||||
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 || listVisibleRecents().some((r) => !r.missing),
|
enabled: !!getState().bookPath || listRecents().some((r) => !r.missing),
|
||||||
click: () => {
|
click: () => {
|
||||||
const target =
|
const target =
|
||||||
getState().bookPath ??
|
getState().bookPath ??
|
||||||
listVisibleRecents().find((r) => !r.missing)?.path ??
|
listRecents().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,22 +5,6 @@ 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),
|
|
||||||
// Synchronously claim a right-click on a recents row so the main process
|
|
||||||
// pops the dedicated book menu instead of the generic edit menu.
|
|
||||||
claimRecentsContext: (
|
|
||||||
p: string,
|
|
||||||
title: string,
|
|
||||||
hidden?: boolean,
|
|
||||||
missing?: boolean,
|
|
||||||
x?: number,
|
|
||||||
y?: number
|
|
||||||
): boolean =>
|
|
||||||
ipcRenderer.sendSync("folio:recents-contextmenu-sync", { path: p, title, hidden, missing }, x, y) === true,
|
|
||||||
onRecentsMenu: (
|
|
||||||
cb: (msg: { action: string; entry: { path: string; title: string; hidden?: boolean; missing?: boolean } }) => void
|
|
||||||
) => ipcRenderer.on("folio:recents-menu", (_e, msg) => cb(msg)),
|
|
||||||
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,9 +37,6 @@ 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 {
|
||||||
|
|
@ -124,10 +121,8 @@ 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, hidden: false });
|
recents.unshift(recent);
|
||||||
writeJsonFile(filePath, recents);
|
writeJsonFile(filePath, recents);
|
||||||
return recents;
|
return recents;
|
||||||
}
|
}
|
||||||
|
|
@ -145,22 +140,3 @@ 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,8 +6,6 @@ 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";
|
||||||
|
|
@ -62,8 +60,6 @@ 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.
|
||||||
|
|
@ -78,12 +74,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -95,11 +85,3 @@ 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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -116,43 +116,6 @@ export function isTrustedDownloadUrl(url: string, base: string): boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive-integrity checks for the updater, as pure functions so they are
|
|
||||||
// unit-testable without network or an app instance.
|
|
||||||
//
|
|
||||||
// A gzip stream is only provably complete when the decompressor reaches its
|
|
||||||
// end without error: a download truncated near the end still carries the
|
|
||||||
// 1f 8b magic, so magic-byte checks alone let corrupt updates through (this
|
|
||||||
// bit us twice: 0.1.2 and 0.1.3 both downloaded truncated and the detached
|
|
||||||
// installer silently kept the old app). verifyArchiveGzip streams the file
|
|
||||||
// through a real gunzip and requires the stream to finish cleanly.
|
|
||||||
import { createReadStream } from "fs";
|
|
||||||
import { createGunzip } from "zlib";
|
|
||||||
|
|
||||||
export function verifyArchiveGzip(file: string): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const rs = createReadStream(file);
|
|
||||||
const gz = createGunzip();
|
|
||||||
let settled = false;
|
|
||||||
const done = (v: boolean) => {
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
resolve(v);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
rs.on("error", () => done(false));
|
|
||||||
gz.on("error", () => done(false));
|
|
||||||
gz.on("end", () => done(true));
|
|
||||||
// Discard output; we only care whether the stream completes.
|
|
||||||
gz.resume();
|
|
||||||
rs.pipe(gz);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// True when a byte-count is known and the download fell short of it.
|
|
||||||
export function looksTruncated(received: number, total: number): boolean {
|
|
||||||
return total > 0 && received < total;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single-quote a path for safe interpolation into /bin/sh scripts.
|
// Single-quote a path for safe interpolation into /bin/sh scripts.
|
||||||
export function shQuote(p: string): string {
|
export function shQuote(p: string): string {
|
||||||
return `'${String(p).replace(/'/g, `'\\''`)}'`;
|
return `'${String(p).replace(/'/g, `'\\''`)}'`;
|
||||||
|
|
@ -163,8 +126,6 @@ export interface InstallerSpec {
|
||||||
tarball: string;
|
tarball: string;
|
||||||
installDir: string;
|
installDir: string;
|
||||||
relaunch: string;
|
relaunch: string;
|
||||||
/** Optional append-only log; the script narrates every step into it. */
|
|
||||||
log?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The handoff script. Deliberately forgiving (no set -e): on any failure it
|
// The handoff script. Deliberately forgiving (no set -e): on any failure it
|
||||||
|
|
@ -172,10 +133,7 @@ export interface InstallerSpec {
|
||||||
// user without an app. The old tree is kept as .old.<pid> and pruned only
|
// user without an app. The old tree is kept as .old.<pid> and pruned only
|
||||||
// after a successful swap.
|
// after a successful swap.
|
||||||
export function buildInstallerScript(spec: InstallerSpec): string {
|
export function buildInstallerScript(spec: InstallerSpec): string {
|
||||||
const { pid, tarball, installDir, relaunch, log } = spec;
|
const { pid, tarball, installDir, relaunch } = spec;
|
||||||
const logLine = log
|
|
||||||
? `say() { echo "$(date '+%F %T') $*" >> ${shQuote(log)} 2>/dev/null; }`
|
|
||||||
: "say() { :; }";
|
|
||||||
return [
|
return [
|
||||||
"#!/bin/sh",
|
"#!/bin/sh",
|
||||||
"# Folio updater — waits for the app to exit, swaps in the new version,",
|
"# Folio updater — waits for the app to exit, swaps in the new version,",
|
||||||
|
|
@ -186,52 +144,30 @@ export function buildInstallerScript(spec: InstallerSpec): string {
|
||||||
`RELAUNCH=${shQuote(relaunch)}`,
|
`RELAUNCH=${shQuote(relaunch)}`,
|
||||||
`NEW="$INSTALL.update-new.$$"`,
|
`NEW="$INSTALL.update-new.$$"`,
|
||||||
`OLD="$INSTALL.old.$$"`,
|
`OLD="$INSTALL.old.$$"`,
|
||||||
logLine,
|
|
||||||
'say "handoff start pid=$PID tarball=$TARBALL"',
|
|
||||||
"# Wait (bounded to ~2 minutes) for the app process to exit.",
|
"# Wait (bounded to ~2 minutes) for the app process to exit.",
|
||||||
"i=0",
|
"i=0",
|
||||||
'while [ "$PID" -gt 0 ] 2>/dev/null && kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done',
|
'while [ "$PID" -gt 0 ] 2>/dev/null && kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done',
|
||||||
'kill -0 "$PID" 2>/dev/null && say "WARN: app still alive after wait window" || say "app exited after ${i} polls"',
|
|
||||||
'rm -rf "$NEW"',
|
'rm -rf "$NEW"',
|
||||||
'mkdir -p "$NEW" || { say "FAIL: mkdir $NEW"; "$RELAUNCH" >/dev/null 2>&1 & exit 1; }',
|
'mkdir -p "$NEW"',
|
||||||
'if tar -xzf "$TARBALL" -C "$NEW"; then',
|
'if tar -xzf "$TARBALL" -C "$NEW"; then',
|
||||||
' say "extracted ok"',
|
' # electron-builder nests everything under a top-level dir; flatten it.',
|
||||||
' # electron-builder nests everything under a top-level dir; flatten it',
|
|
||||||
" # by adopting the nested dir itself (mv of the dir, not a glob — globs",
|
|
||||||
" # miss dotfiles and a partial mv leaves a broken tree).",
|
|
||||||
' if [ ! -e "$NEW/folio" ]; then',
|
' if [ ! -e "$NEW/folio" ]; then',
|
||||||
' ONLY=$(find "$NEW" -mindepth 1 -maxdepth 1 -type d | head -n 1)',
|
' ONLY=$(find "$NEW" -mindepth 1 -maxdepth 1 -type d | head -n 1)',
|
||||||
' REST=$(find "$NEW" -mindepth 1 -maxdepth 1 | wc -l)',
|
' [ -n "$ONLY" ] && mv "$ONLY"/* "$NEW"/ 2>/dev/null',
|
||||||
' if [ -n "$ONLY" ] && [ "$REST" -eq 1 ]; then',
|
|
||||||
' mv "$ONLY" "$NEW.new" && rm -rf "$NEW" && mv "$NEW.new" "$NEW" && say "flattened nested dir"',
|
|
||||||
" fi",
|
" fi",
|
||||||
" fi",
|
|
||||||
"else",
|
|
||||||
' say "FAIL: tar extract failed"',
|
|
||||||
"fi",
|
"fi",
|
||||||
'if [ -x "$NEW/folio" ]; then',
|
'if [ -x "$NEW/folio" ]; then',
|
||||||
' [ -e "$INSTALL" ] && mv "$INSTALL" "$OLD" && say "moved old install aside"',
|
' [ -e "$INSTALL" ] && mv "$INSTALL" "$OLD"',
|
||||||
' if mv "$NEW" "$INSTALL"; then',
|
' if mv "$NEW" "$INSTALL"; then',
|
||||||
' say "SWAP OK — new version installed"',
|
|
||||||
' rm -rf "$OLD" "$INSTALL.update-new".* "$INSTALL.old".* 2>/dev/null',
|
' rm -rf "$OLD" "$INSTALL.update-new".* "$INSTALL.old".* 2>/dev/null',
|
||||||
' rm -f "$TARBALL"',
|
' rm -f "$TARBALL"',
|
||||||
" else",
|
" else",
|
||||||
' say "FAIL: mv NEW->INSTALL; restoring old"',
|
|
||||||
' [ -e "$OLD" ] && mv "$OLD" "$INSTALL"',
|
' [ -e "$OLD" ] && mv "$OLD" "$INSTALL"',
|
||||||
" fi",
|
" fi",
|
||||||
"else",
|
"else",
|
||||||
' say "FAIL: no executable at $NEW/folio; keeping old install"',
|
|
||||||
' rm -rf "$NEW"',
|
' rm -rf "$NEW"',
|
||||||
"fi",
|
"fi",
|
||||||
'say "relaunching"',
|
'"$RELAUNCH" >/dev/null 2>&1 &',
|
||||||
// Detach fully: setsid moves the relaunch into its own session so it
|
|
||||||
// survives this script's exit and any cgroup sweep of the transient
|
|
||||||
// unit (systemd KillMode). Fallback chain for missing setsid.
|
|
||||||
'if command -v setsid >/dev/null 2>&1; then',
|
|
||||||
' setsid "$RELAUNCH" >/dev/null 2>&1 < /dev/null &',
|
|
||||||
'else',
|
|
||||||
' nohup "$RELAUNCH" >/dev/null 2>&1 < /dev/null &',
|
|
||||||
"fi",
|
|
||||||
"",
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@ interface SnapshotSettings {
|
||||||
keep: number;
|
keep: number;
|
||||||
location: string;
|
location: string;
|
||||||
resolvedRoot: string;
|
resolvedRoot: string;
|
||||||
/** Where new books are saved (Settings → Storage folder). */
|
|
||||||
booksDir?: string;
|
|
||||||
snapshotCount: number;
|
snapshotCount: number;
|
||||||
lastSnapshot: string | null;
|
lastSnapshot: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -61,10 +59,8 @@ export function createSettingsFeature(ctx: BookCtx): SettingsFeature {
|
||||||
intervalInput.value = String(s.intervalMin ?? 5);
|
intervalInput.value = String(s.intervalMin ?? 5);
|
||||||
keepInput.value = String(s.keep ?? 20);
|
keepInput.value = String(s.keep ?? 20);
|
||||||
locationInput.value = s.location || "";
|
locationInput.value = s.location || "";
|
||||||
locationInput.placeholder = "Default: Documents (books) · <book>/Folio-Backups (snapshots)";
|
locationInput.placeholder = "Default: <book>/Folio-Backups";
|
||||||
resolvedEl.textContent = s.booksDir
|
resolvedEl.textContent = `Saving to: ${shortPath(s.resolvedRoot || "—")}`;
|
||||||
? `New books save to: ${shortPath(s.booksDir)}`
|
|
||||||
: `Saving to: ${shortPath(s.resolvedRoot || "—")}`;
|
|
||||||
lastSnapEl.textContent = s.lastSnapshot
|
lastSnapEl.textContent = s.lastSnapshot
|
||||||
? `${s.snapshotCount} snapshot${s.snapshotCount === 1 ? "" : "s"} · latest ${s.lastSnapshot.split("/").pop()}`
|
? `${s.snapshotCount} snapshot${s.snapshotCount === 1 ? "" : "s"} · latest ${s.lastSnapshot.split("/").pop()}`
|
||||||
: "No snapshots yet.";
|
: "No snapshots yet.";
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@ 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 {
|
||||||
|
|
@ -41,19 +39,6 @@ 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>;
|
|
||||||
claimRecentsContext: (
|
|
||||||
p: string,
|
|
||||||
title: string,
|
|
||||||
hidden?: boolean,
|
|
||||||
missing?: boolean,
|
|
||||||
x?: number,
|
|
||||||
y?: number
|
|
||||||
) => boolean;
|
|
||||||
onRecentsMenu: (
|
|
||||||
cb: (msg: { action: string; entry: { path: string; title: string; hidden?: boolean; missing?: boolean } }) => void
|
|
||||||
) => void;
|
|
||||||
closeBook: () => Promise<boolean>;
|
closeBook: () => Promise<boolean>;
|
||||||
getSpellcheck: () => Promise<boolean>;
|
getSpellcheck: () => Promise<boolean>;
|
||||||
setSpellcheck: (on: boolean) => Promise<any>;
|
setSpellcheck: (on: boolean) => Promise<any>;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import type { BookCtx } from "./types.js";
|
import type { BookCtx } from "./types.js";
|
||||||
import type { ChapterEntry, Recent } from "./types.js";
|
import type { ChapterEntry, Recent } from "./types.js";
|
||||||
import type { BookMeta } from "../../main/project.js";
|
import type { BookMeta } from "../../main/project.js";
|
||||||
import { alertMessage, confirmMessage, promptInput } from "../dialog.js";
|
import { alertMessage, promptInput } from "../dialog.js";
|
||||||
import { $, showUndoToast, showView } from "./ui.js";
|
import { $, showUndoToast, showView } from "./ui.js";
|
||||||
|
|
||||||
export interface WelcomeFeature {
|
export interface WelcomeFeature {
|
||||||
|
|
@ -185,64 +185,25 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
||||||
try {
|
try {
|
||||||
const recents = await api.getRecents();
|
const recents = await api.getRecents();
|
||||||
recentList.innerHTML = "";
|
recentList.innerHTML = "";
|
||||||
const visible = (recents as (Recent & { missing?: boolean })[]).filter(
|
if (!recents.length) {
|
||||||
(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 visible) {
|
for (const r of recents as (Recent & { missing?: boolean })[]) {
|
||||||
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" + (isHidden ? " recent-hidden-entry" : "");
|
li.className = "recent-item";
|
||||||
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/Hide/Delete actions);
|
// Single click selects the row (reveals Rename/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));
|
||||||
// Right-click (two-finger tap) on a recents row opens a dedicated
|
|
||||||
// context menu (Open / Rename / Hide / Delete / Show in Folder). The
|
|
||||||
// renderer claims the event synchronously (sendSync) so the main
|
|
||||||
// process skips the generic spelling/edit menu for this target; if the
|
|
||||||
// claim ever fails, fall back to selecting the row inline.
|
|
||||||
li.addEventListener("contextmenu", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
const claimed = api.claimRecentsContext(
|
|
||||||
r.path,
|
|
||||||
r.title || r.path,
|
|
||||||
!!r.hidden,
|
|
||||||
!!r.missing,
|
|
||||||
e.clientX,
|
|
||||||
e.clientY
|
|
||||||
);
|
|
||||||
if (claimed) return;
|
|
||||||
} catch {
|
|
||||||
/* fall through to inline selection */
|
|
||||||
}
|
|
||||||
selectRecent(li);
|
|
||||||
});
|
|
||||||
li.addEventListener("dblclick", () => void openPath(r.path));
|
li.addEventListener("dblclick", () => void openPath(r.path));
|
||||||
li.addEventListener("keydown", (e) => {
|
li.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Enter") void openPath(r.path);
|
if (e.key === "Enter") void openPath(r.path);
|
||||||
|
|
@ -272,35 +233,6 @@ 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";
|
||||||
|
|
@ -331,29 +263,11 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
||||||
actions.appendChild(del);
|
actions.appendChild(del);
|
||||||
li.appendChild(label);
|
li.appendChild(label);
|
||||||
li.appendChild(actions);
|
li.appendChild(actions);
|
||||||
return li;
|
recentList.appendChild(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
|
||||||
|
|
@ -385,61 +299,6 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
||||||
if (state.currentBook) void api.revealInFolder(state.currentBook.path);
|
if (state.currentBook) void api.revealInFolder(state.currentBook.path);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Right-click menu actions (popped by the main process; see
|
|
||||||
// folio:recents-contextmenu-sync). Runs the same operations as the row
|
|
||||||
// buttons so behavior is identical from either surface.
|
|
||||||
api.onRecentsMenu(async ({ action, entry }) => {
|
|
||||||
switch (action) {
|
|
||||||
case "open":
|
|
||||||
await openPath(entry.path);
|
|
||||||
return;
|
|
||||||
case "rename": {
|
|
||||||
const name = await promptInput("Rename book", entry.title || "");
|
|
||||||
if (name === null) return;
|
|
||||||
const trimmed = name.trim();
|
|
||||||
if (!trimmed || trimmed === entry.title) return;
|
|
||||||
const res = await api.renameBook(entry.path, trimmed);
|
|
||||||
if (res && "error" in res) {
|
|
||||||
await alertMessage("Could not rename", (res as { error: string }).error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await renderRecents();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "reveal":
|
|
||||||
await api.revealInFolder(entry.path);
|
|
||||||
return;
|
|
||||||
case "hide":
|
|
||||||
await api.hideRecent(entry.path);
|
|
||||||
await renderRecents();
|
|
||||||
return;
|
|
||||||
case "unhide":
|
|
||||||
await api.unhideRecent(entry.path);
|
|
||||||
await renderRecents();
|
|
||||||
return;
|
|
||||||
case "remove":
|
|
||||||
// Stale entry (book gone): no confirmation needed, nothing to lose.
|
|
||||||
await api.deleteBook(entry.path);
|
|
||||||
await renderRecents();
|
|
||||||
return;
|
|
||||||
case "delete": {
|
|
||||||
const yes = await confirmMessage(
|
|
||||||
`Delete "${entry.title}"?`,
|
|
||||||
"The whole book folder moves to the system trash. Hide from Recents removes it from the list without touching the disk.",
|
|
||||||
"Move to Trash"
|
|
||||||
);
|
|
||||||
if (!yes) return;
|
|
||||||
const res = await api.deleteBook(entry.path);
|
|
||||||
if (res && "error" in res) {
|
|
||||||
await alertMessage("Could not delete", (res as { error: string }).error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await renderRecents();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showWelcome,
|
showWelcome,
|
||||||
showPlaceholder,
|
showPlaceholder,
|
||||||
|
|
|
||||||
|
|
@ -665,50 +665,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -343,9 +343,9 @@
|
||||||
<label>Keep last <input type="number" id="settingKeep" class="modal-input setting-num" min="0" max="200" value="20" /></label>
|
<label>Keep last <input type="number" id="settingKeep" class="modal-input setting-num" min="0" max="200" value="20" /></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="backup-control">
|
<div class="backup-control">
|
||||||
<span class="backup-label">Storage folder</span>
|
<span class="backup-label">Folder</span>
|
||||||
<input type="text" id="settingLocation" class="backup-input" readonly placeholder="Default: Documents" title="Where new books and snapshots are saved (empty = Documents / <book>/Folio-Backups)" />
|
<input type="text" id="settingLocation" class="backup-input" readonly placeholder="Default: <book>/Folio-Backups" title="Snapshot folder (empty = default)" />
|
||||||
<button type="button" class="button button-ghost backup-browse" id="settingBrowse" title="Choose storage folder">…</button>
|
<button type="button" class="button button-ghost backup-browse" id="settingBrowse" title="Choose snapshot folder">…</button>
|
||||||
<button type="button" class="button button-ghost" id="settingResetLoc" title="Back to the default folder">Reset</button>
|
<button type="button" class="button button-ghost" id="settingResetLoc" title="Back to the default folder">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="setting-meta" id="settingResolved"></p>
|
<p class="setting-meta" id="settingResolved"></p>
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ const {
|
||||||
hasBook,
|
hasBook,
|
||||||
listRecents,
|
listRecents,
|
||||||
addRecent,
|
addRecent,
|
||||||
hideRecent,
|
|
||||||
unhideRecent,
|
|
||||||
makeChapterId,
|
makeChapterId,
|
||||||
chapterFileName,
|
chapterFileName,
|
||||||
} = mod;
|
} = mod;
|
||||||
|
|
@ -67,19 +65,6 @@ 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");
|
||||||
|
|
|
||||||
|
|
@ -118,32 +118,6 @@ function writeScript(box, spec) {
|
||||||
fs.rmSync(box, { recursive: true, force: true });
|
fs.rmSync(box, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- archive integrity: verifyArchiveGzip + looksTruncated ---
|
|
||||||
{
|
|
||||||
const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-gz-"));
|
|
||||||
const good = path.join(box, "good.tar.gz");
|
|
||||||
const zlib = await import("zlib");
|
|
||||||
fs.writeFileSync(good, zlib.gzipSync(Buffer.from("hello folio updater ".repeat(1000))));
|
|
||||||
ok(await u.verifyArchiveGzip(good), "complete gzip verifies");
|
|
||||||
|
|
||||||
// Truncate the good archive mid-stream: magic bytes present, payload short.
|
|
||||||
const full = fs.readFileSync(good);
|
|
||||||
const trunc = path.join(box, "trunc.tar.gz");
|
|
||||||
fs.writeFileSync(trunc, full.subarray(0, full.length - 50));
|
|
||||||
ok(!(await u.verifyArchiveGzip(trunc)), "truncated gzip REJECTED (the 0.1.2/0.1.3 bug)");
|
|
||||||
|
|
||||||
const notgz = path.join(box, "notgz.tar.gz");
|
|
||||||
fs.writeFileSync(notgz, "<html>error page</html>");
|
|
||||||
ok(!(await u.verifyArchiveGzip(notgz)), "non-gzip content rejected");
|
|
||||||
ok(!(await u.verifyArchiveGzip(path.join(box, "missing.tar.gz"))), "missing file rejected");
|
|
||||||
|
|
||||||
ok(u.looksTruncated(50, 100), "short byte count flagged");
|
|
||||||
ok(!u.looksTruncated(100, 100), "exact byte count passes");
|
|
||||||
ok(!u.looksTruncated(120, 100), "over-long passes");
|
|
||||||
ok(!u.looksTruncated(7, 0), "unknown total not flagged");
|
|
||||||
fs.rmSync(box, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupBundle();
|
cleanupBundle();
|
||||||
if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); }
|
if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); }
|
||||||
console.log("All installer checks passed.");
|
console.log("All installer checks passed.");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue