Sort a notebook, import Markdown back, and stop the page shrinking in Draw
The paper measurably shrank on switching to Draw — 733px to 460px on the same screen. The editor was a flex item at its default `flex: 0 1 auto`, so its width was its widest row's: the toolbar. Swapping a dozen formatting buttons for six drawing ones collapsed it. It now fills the pane, so the width is a property of the window. Each notebook remembers how its notes are ordered, on the server rather than per device. The manual order has to survive being looked away from, so `reindex` renumbers every note in the notebook rather than the filtered view, and dragging is switched off while a search narrows the list. Reordering has its own route: `PUT /notes/:id` would bump `updated_at` and snapshot a version, so a drag would move a note to the top of "last changed" and spend a slot of its history. Markdown and our own ZIP archives come back as notes, with their images re-uploaded as attachments. A drawing returns as the PNG it was exported as — vectors are not recoverable from pixels — and the import says so rather than leaving it to be discovered. Writing the parser turned up a real bug in the exporter: blocks were emitted with no blank line between them, so a list after a list became one list and a quote after an item was read as part of it. Creating or importing from "All Notes" now asks which notebook instead of silently taking the first one. The web-link prompt is a real dialog: a bare domain becomes https, javascript: is refused with a reason. The PDF viewer's hide-drawing control is a labelled button rather than a filled-or-hollow dot, and the export icon points down into a tray like every other download. 186 unit tests, 179 browser checks across eleven suites, including a new `offline` suite that loads the app cold with the network off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3acb8b66a4
commit
0a0864c58b
27 files changed
+2962
-86
No files matched your search
+4
-4
@@ -100,7 +100,7 @@ try {
|
||||
|
||||
/* ── single note as Markdown ── */
|
||||
await openExportDialog(p);
|
||||
await p.getByRole("button", { name: /Markdown/ }).click();
|
||||
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||
await p.waitForTimeout(600);
|
||||
|
||||
@@ -139,7 +139,7 @@ try {
|
||||
|
||||
await clearDownloads(p);
|
||||
await openExportDialog(p);
|
||||
await p.getByRole("button", { name: /Markdown/ }).click();
|
||||
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||
await p.waitForTimeout(800);
|
||||
files = await downloads(p);
|
||||
@@ -184,7 +184,7 @@ try {
|
||||
await p.waitForTimeout(500);
|
||||
check((await p.getByRole("button", { name: /PNG-Bild/ }).count()) > 0, "PNG is offered for the drawing");
|
||||
check(
|
||||
(await p.getByRole("button", { name: /Markdown/ }).count()) === 0,
|
||||
(await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).count()) === 0,
|
||||
"Markdown is not offered for a drawing, since it cannot hold one"
|
||||
);
|
||||
await p.getByRole("button", { name: /PNG-Bild/ }).click();
|
||||
@@ -208,7 +208,7 @@ try {
|
||||
await p.getByRole("button", { name: /Nur den Notiztext/ }).click();
|
||||
await p.waitForTimeout(500);
|
||||
await clearDownloads(p);
|
||||
await p.getByRole("button", { name: /Markdown/ }).click();
|
||||
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||
await p.waitForTimeout(800);
|
||||
files = await downloads(p);
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/* Reading without a connection.
|
||||
*
|
||||
* The claim being tested is the one the app makes by being a PWA at all: a note
|
||||
* you have seen is a note you can still read on a train. That has two halves,
|
||||
* and both have to hold — the application itself has to load from the cache, and
|
||||
* the notes have to already be on the device rather than one request away.
|
||||
*
|
||||
* "Offline" here means the browser context is switched offline, which is what
|
||||
* losing a connection actually looks like to the page: requests fail, the
|
||||
* service worker still answers, IndexedDB still works.
|
||||
*/
|
||||
import {
|
||||
api,
|
||||
check,
|
||||
createNote,
|
||||
createNotebook,
|
||||
launch,
|
||||
report,
|
||||
setTitle,
|
||||
typeInEditor,
|
||||
withFreshUser,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const browser = await launch();
|
||||
let session;
|
||||
try {
|
||||
session = await withFreshUser(browser, "offline");
|
||||
const p = session.page;
|
||||
const context = p.context();
|
||||
|
||||
await createNotebook(p, "Unterwegs");
|
||||
await p.locator("button").filter({ hasText: "Unterwegs" }).first().click();
|
||||
await p.waitForTimeout(700);
|
||||
|
||||
for (const [title, body] of [
|
||||
["Reiseplan", "Abfahrt um sieben Uhr vom Hauptbahnhof."],
|
||||
["Einkauf", "Milch, Brot, Kaffee."],
|
||||
]) {
|
||||
await createNote(p);
|
||||
await setTitle(p, title);
|
||||
await typeInEditor(p, body);
|
||||
await p.waitForTimeout(900);
|
||||
}
|
||||
|
||||
/* The service worker has to be in charge before it can answer anything. */
|
||||
const controlled = await p.evaluate(async () => {
|
||||
if (!("serviceWorker" in navigator)) return "unsupported";
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
return reg.active ? "active" : "no active worker";
|
||||
});
|
||||
check(controlled === "active", "a service worker is installed and running", controlled);
|
||||
|
||||
/* Bodies reach the device on demand, so a note never opened would not be
|
||||
readable offline. The background backfill exists for exactly that; give it
|
||||
a moment and then confirm every note is really in local storage. */
|
||||
await p.waitForTimeout(4000);
|
||||
const cached = await p.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const open = indexedDB.open("inknote");
|
||||
open.onerror = () => resolve({ error: String(open.error) });
|
||||
open.onsuccess = () => {
|
||||
const idb = open.result;
|
||||
if (!idb.objectStoreNames.contains("noteContents")) return resolve({ error: "no store" });
|
||||
const tx = idb.transaction("noteContents", "readonly");
|
||||
const all = tx.objectStore("noteContents").getAll();
|
||||
all.onsuccess = () =>
|
||||
resolve({
|
||||
count: all.result.length,
|
||||
texts: all.result.map((r) => String(r.content || "")),
|
||||
});
|
||||
all.onerror = () => resolve({ error: String(all.error) });
|
||||
};
|
||||
})
|
||||
);
|
||||
check(!cached.error, "the local copy of the notes can be read", cached.error || "");
|
||||
check(cached.count >= 2, "every note's body is on the device, not only the open one", `${cached.count} bodies`);
|
||||
check(
|
||||
cached.texts.some((t) => t.includes("Hauptbahnhof")),
|
||||
"including one that was written earlier and then left"
|
||||
);
|
||||
|
||||
/* ── pull the plug ── */
|
||||
await context.setOffline(true);
|
||||
await p.waitForTimeout(500);
|
||||
|
||||
const blocked = await p.evaluate(async () => {
|
||||
try {
|
||||
await fetch("/api/notes", { cache: "no-store" });
|
||||
return "still answering";
|
||||
} catch {
|
||||
return "blocked";
|
||||
}
|
||||
});
|
||||
check(blocked === "blocked", "the network really is gone", blocked);
|
||||
|
||||
/* A cold start: not a soft re-render, a full page load with no connection. */
|
||||
await p.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
await p.waitForTimeout(4000);
|
||||
|
||||
const loaded = await p.locator("body").innerText();
|
||||
check(loaded.length > 40, "the app itself loads from the cache with no connection", `${loaded.length} chars`);
|
||||
check(!/Sign In/i.test(loaded), "and the session is still there — no login screen offline");
|
||||
|
||||
await p.locator("button").filter({ hasText: "Unterwegs" }).first().click();
|
||||
await p.waitForTimeout(1500);
|
||||
const rows = await p.locator("[data-note-row]").count();
|
||||
check(rows >= 2, "the notebook still lists its notes", `${rows} rows`);
|
||||
|
||||
await p.locator("[data-note-row]").filter({ hasText: "Reiseplan" }).first().click();
|
||||
await p.waitForTimeout(2500);
|
||||
const body = await p.locator(".ProseMirror").innerText();
|
||||
check(body.includes("Hauptbahnhof"), "and a note opens with its text", JSON.stringify(body.slice(0, 60)));
|
||||
|
||||
/* Searching is local by design — the server holds only ciphertext — so it has
|
||||
to keep working when the server is not there at all. */
|
||||
await p.getByPlaceholder("Search notes...").fill("Kaffee");
|
||||
await p.waitForTimeout(1500);
|
||||
const hits = await p.locator("[data-note-row]").count();
|
||||
check(hits === 1, "search still finds a note by its text, offline", `${hits} hits`);
|
||||
await p.getByPlaceholder("Search notes...").fill("");
|
||||
await p.waitForTimeout(800);
|
||||
|
||||
/* Writing offline is queued, not lost. */
|
||||
await p.locator("[data-note-row]").filter({ hasText: "Einkauf" }).first().click();
|
||||
await p.waitForTimeout(1800);
|
||||
await p.locator(".ProseMirror").click();
|
||||
await p.keyboard.press("Control+End");
|
||||
await p.keyboard.type(" Und Butter.");
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
const status = await p.locator("body").innerText();
|
||||
check(/offline|Pending|Wartet|Änderungen/i.test(status), "the app says it is offline rather than pretending");
|
||||
|
||||
/* ── back on ── */
|
||||
await context.setOffline(false);
|
||||
await p.waitForTimeout(1000);
|
||||
await p.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await p.waitForTimeout(8000);
|
||||
|
||||
const server = (await api(p, "GET", "/notes")).body;
|
||||
const einkauf = server.find((n) => n.title === "Einkauf");
|
||||
const stored = await api(p, "GET", `/notes/${einkauf.id}`);
|
||||
check(
|
||||
String(stored.body.content).includes("Butter"),
|
||||
"and the edit made offline reaches the server once there is a connection",
|
||||
String(stored.body.content).slice(0, 80)
|
||||
);
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.message);
|
||||
} finally {
|
||||
await session?.cleanup();
|
||||
const code = report();
|
||||
await browser.close();
|
||||
process.exit(code);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/* Sorting, importing, and the two dialogs that replaced a guess and a prompt.
|
||||
*
|
||||
* The load-bearing claim here is the one about sorting: an arrangement the user
|
||||
* made by hand has to survive being looked away from. Searching imposes its own
|
||||
* order, and the danger is that going back to "own order" shows something
|
||||
* nobody arranged — because the reorder was computed against a filtered list.
|
||||
* That is checked by arranging, searching, and coming back.
|
||||
*/
|
||||
import {
|
||||
api,
|
||||
check,
|
||||
createNote,
|
||||
createNotebook,
|
||||
launch,
|
||||
report,
|
||||
setTitle,
|
||||
typeInEditor,
|
||||
withFreshUser,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const browser = await launch();
|
||||
let session;
|
||||
try {
|
||||
session = await withFreshUser(browser, "organise");
|
||||
const p = session.page;
|
||||
|
||||
const titles = () =>
|
||||
p.locator("[data-note-row]").evaluateAll((rows) =>
|
||||
rows.map((r) => r.querySelector("span:nth-child(2)")?.textContent?.trim() || "")
|
||||
);
|
||||
const sortBy = async (mode) => {
|
||||
await p.locator("[data-sort-button]").click();
|
||||
await p.waitForTimeout(300);
|
||||
await p.locator(`[data-sort-option="${mode}"]`).click();
|
||||
await p.waitForTimeout(900);
|
||||
};
|
||||
|
||||
/* ── three notes, made in a known order ── */
|
||||
await createNotebook(p, "Ordnung");
|
||||
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||||
await p.waitForTimeout(700);
|
||||
|
||||
for (const name of ["Alpha", "Beta", "Gamma"]) {
|
||||
await createNote(p);
|
||||
await setTitle(p, name);
|
||||
await typeInEditor(p, `Inhalt von ${name}. `);
|
||||
await p.waitForTimeout(700);
|
||||
}
|
||||
await p.waitForTimeout(1200);
|
||||
|
||||
check((await titles()).length === 3, "three notes to arrange", (await titles()).join(", "));
|
||||
|
||||
/* ── the built-in orders ── */
|
||||
await sortBy("updated");
|
||||
check((await titles())[0] === "Gamma", "newest first by default", (await titles()).join(", "));
|
||||
|
||||
await sortBy("oldest");
|
||||
check((await titles())[0] === "Alpha", "oldest first reverses it", (await titles()).join(", "));
|
||||
|
||||
await sortBy("title");
|
||||
check((await titles()).join(",") === "Alpha,Beta,Gamma", "A–Z sorts by title", (await titles()).join(", "));
|
||||
|
||||
/* ── it is remembered, not just applied ── */
|
||||
await p.reload({ waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(2500);
|
||||
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||||
await p.waitForTimeout(1500);
|
||||
check(
|
||||
(await titles()).join(",") === "Alpha,Beta,Gamma",
|
||||
"and it survives a reload, because it belongs to the notebook",
|
||||
(await titles()).join(", ")
|
||||
);
|
||||
|
||||
const notebooks = await api(p, "GET", "/notebooks");
|
||||
check(notebooks.body[0].sort_mode === "title", "the notebook itself carries the choice", String(notebooks.body[0].sort_mode));
|
||||
|
||||
/* ── the user's own order ── */
|
||||
await sortBy("manual");
|
||||
const beforeDrag = await titles();
|
||||
check(beforeDrag.length === 3, "manual order starts from the notes it has", beforeDrag.join(", "));
|
||||
|
||||
// Drag the last note onto the first.
|
||||
const rows = p.locator("[data-note-row]");
|
||||
const last = rows.nth(2);
|
||||
const first = rows.nth(0);
|
||||
await last.hover();
|
||||
await p.mouse.down();
|
||||
await first.hover();
|
||||
await first.hover(); // a second move, so the drop target registers
|
||||
await p.mouse.up();
|
||||
await p.waitForTimeout(1500);
|
||||
|
||||
const arranged = await titles();
|
||||
check(
|
||||
arranged[0] === beforeDrag[2],
|
||||
"dragging a note to the top puts it there",
|
||||
`${beforeDrag.join(",")} → ${arranged.join(",")}`
|
||||
);
|
||||
|
||||
/* The point of the whole feature. */
|
||||
await p.getByPlaceholder("Search notes...").fill("Beta");
|
||||
await p.waitForTimeout(1200);
|
||||
const searched = await titles();
|
||||
check(searched.length < 3, "searching narrows the list", searched.join(", "));
|
||||
|
||||
await p.getByPlaceholder("Search notes...").fill("");
|
||||
await p.waitForTimeout(1500);
|
||||
check(
|
||||
(await titles()).join(",") === arranged.join(","),
|
||||
"and clearing the search shows exactly the arrangement again",
|
||||
`${arranged.join(",")} vs ${(await titles()).join(",")}`
|
||||
);
|
||||
|
||||
/* Reordering must not look like editing: a dragged note keeps its place in
|
||||
"last changed", or the two orders would fight each other. */
|
||||
await sortBy("updated");
|
||||
check(
|
||||
(await titles())[0] === "Gamma",
|
||||
"a dragged note did not become the most recently changed one",
|
||||
(await titles()).join(", ")
|
||||
);
|
||||
await sortBy("manual");
|
||||
await p.waitForTimeout(800);
|
||||
|
||||
/* ── exporting a note and importing it back ── */
|
||||
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).first().click();
|
||||
await p.waitForTimeout(1200);
|
||||
await p.locator(".ProseMirror").click();
|
||||
await p.keyboard.press("Control+End");
|
||||
await p.keyboard.press("Enter");
|
||||
// Through the toolbar rather than by typing "- [x] ": inside a bullet item
|
||||
// that sequence stays literal text, which is a quirk of the editor's input
|
||||
// rules and not what this suite is about.
|
||||
await p.getByTitle("Task list").click();
|
||||
await p.waitForTimeout(400);
|
||||
await p.keyboard.type("erledigt");
|
||||
await p.waitForTimeout(600);
|
||||
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
|
||||
await p.waitForTimeout(2000);
|
||||
|
||||
const exported = await p.evaluate(async () => {
|
||||
const captured = [];
|
||||
const realCreate = URL.createObjectURL.bind(URL);
|
||||
URL.createObjectURL = (blob) => {
|
||||
captured.push(blob);
|
||||
return realCreate(blob);
|
||||
};
|
||||
window.__captured = captured;
|
||||
HTMLAnchorElement.prototype.click = function () {};
|
||||
return true;
|
||||
});
|
||||
check(exported, "downloads can be captured");
|
||||
|
||||
await p.getByTitle("Export").click();
|
||||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||||
await p.waitForTimeout(600);
|
||||
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
const markdown = await p.evaluate(async () => {
|
||||
const blob = window.__captured?.[0];
|
||||
return blob ? await blob.text() : "";
|
||||
});
|
||||
check(markdown.includes("- [x] erledigt"), "the export carries the ticked box", JSON.stringify(markdown.slice(-60)));
|
||||
check(markdown.startsWith("# Beta"), "and the title as a heading");
|
||||
|
||||
await p.locator("[data-export-dialog] button").filter({ hasText: /Schließen|Fertig|Close/ }).first().click().catch(() => {});
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(600);
|
||||
|
||||
/* Import it back as a second note. A real file chooser cannot be driven, so
|
||||
the file is handed to the input directly — the code under test is what
|
||||
happens after a file is chosen. */
|
||||
const before = (await api(p, "GET", "/notes")).body.length;
|
||||
await p.evaluate((md) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
document.body.appendChild(input);
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File([md], "Beta.md", { type: "text/markdown" }));
|
||||
input.files = dt.files;
|
||||
window.__importInput = input;
|
||||
}, markdown);
|
||||
|
||||
const chooser = p.waitForEvent("filechooser");
|
||||
await p.locator("[data-import-button]").click();
|
||||
const fc = await chooser;
|
||||
await fc.setFiles({ name: "Beta.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) });
|
||||
await p.waitForTimeout(4000);
|
||||
|
||||
const importedText = await p.locator("body").innerText();
|
||||
check(/importiert/i.test(importedText), "the import reports what it did", importedText.slice(0, 200).replace(/\n/g, " "));
|
||||
await p.getByRole("button", { name: /Fertig/ }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
const after = (await api(p, "GET", "/notes")).body.length;
|
||||
check(after === before + 1, "one note more than before", `${before} → ${after}`);
|
||||
|
||||
const importedNote = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Beta" && n.id);
|
||||
check(!!importedNote, "the imported note kept its title from the heading");
|
||||
|
||||
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).last().click();
|
||||
await p.waitForTimeout(1800);
|
||||
const boxes = await p.locator('.ProseMirror input[type="checkbox"]').count();
|
||||
check(boxes > 0, "the task box came back as a real checkbox, not as text", String(boxes));
|
||||
const checked = await p.locator('.ProseMirror input[type="checkbox"]:checked').count();
|
||||
check(checked > 0, "and it came back ticked", String(checked));
|
||||
|
||||
/* ── the link dialog, which used to be a browser prompt ── */
|
||||
await p.locator(".ProseMirror").click();
|
||||
await p.keyboard.press("Control+End");
|
||||
await p.keyboard.type("Zielwort");
|
||||
await p.keyboard.down("Shift");
|
||||
for (let i = 0; i < 8; i++) await p.keyboard.press("ArrowLeft");
|
||||
await p.keyboard.up("Shift");
|
||||
await p.getByTitle("Web link").click();
|
||||
await p.waitForTimeout(600);
|
||||
|
||||
const field = p.getByLabel("Link address");
|
||||
check(await field.isVisible(), "the link dialog is a real dialog in the page");
|
||||
|
||||
await field.fill("javascript:alert(1)");
|
||||
await p.getByRole("button", { name: /Einfügen/ }).click();
|
||||
await p.waitForTimeout(400);
|
||||
check(
|
||||
await field.isVisible(),
|
||||
"a javascript: address is refused rather than inserted"
|
||||
);
|
||||
|
||||
await field.fill("example.com");
|
||||
await p.getByRole("button", { name: /Einfügen/ }).click();
|
||||
await p.waitForTimeout(1800);
|
||||
const href = await p.locator(".ProseMirror a[href]").first().getAttribute("href");
|
||||
check(href === "https://example.com", "a bare domain is taken as https", String(href));
|
||||
|
||||
/* ── which notebook, when there is no notebook ── */
|
||||
await createNotebook(p, "Zweites Buch");
|
||||
await p.waitForTimeout(800);
|
||||
await p.locator("button").filter({ hasText: "All Notes" }).first().click();
|
||||
await p.waitForTimeout(900);
|
||||
await p.getByTitle("New Note").click();
|
||||
await p.waitForTimeout(900);
|
||||
|
||||
const books0 = (await api(p, "GET", "/notebooks")).body;
|
||||
const picker = p.locator("[data-notebook-option]");
|
||||
check((await picker.count()) === 2, "creating a note from All Notes asks where it should go", String(await picker.count()));
|
||||
|
||||
// By name, not by position: two notebooks created without dragging share a
|
||||
// sort order, so "the last one" is not a stable way to mean a particular book.
|
||||
const target = books0.find((b) => b.name === "Zweites Buch");
|
||||
await p.locator(`[data-notebook-option="${target.id}"]`).click();
|
||||
await p.getByRole("button", { name: /Weiter/ }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
// By title, not by position: the list is ordered by a timestamp with one
|
||||
// second of resolution, and several notes here share that second.
|
||||
const fresh = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Untitled Note");
|
||||
check(
|
||||
fresh?.notebook_id === target.id,
|
||||
"and it lands in the notebook that was chosen, not the first one",
|
||||
`${fresh?.notebook_id} vs ${target.id}`
|
||||
);
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.message);
|
||||
} finally {
|
||||
await session?.cleanup();
|
||||
const code = report();
|
||||
await browser.close();
|
||||
process.exit(code);
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
|
||||
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
|
||||
URL="${INKNOTE_URL:-http://localhost:3000}"
|
||||
SUITES=("$@")
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle)
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise offline)
|
||||
|
||||
mkdir -p shots
|
||||
if [ ! -d node_modules ]; then
|
||||
|
||||
Reference in new issue
Block a user