mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Photograph the filters and the calendar, on a mailbox anyone can seed
The README tour was missing both screens, and could not gain them: the suite that captures it needed a demo installation exactly one person had. The E2E fixtures cannot stand in — "E2E Trash Me" is the right name for an assertion and the wrong one for a picture in a readme — so the screenshots could only be retaken by that person, and only they could tell whether they still matched the app. app:test:seed-demo writes a mailbox that reads like mail: ten threads with senders, subjects and labels dull enough to be believable, none of it anyone's real correspondence, which is what the caption under those images promises. It also clears the fixture account and any leftover calendar events, so the same command twice gives the same picture. The calendar events are created through the dialog rather than seeded. An event is a JSCalendar object whose occurrences are materialised on write, and going around the writer to put rows in the database is how a screenshot ends up showing a state the app cannot produce. One bug the camera found: the live match count read "Matches 1 existing messages". The number is substituted in the browser, so the string now carries both forms and the builder picks one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
31db1ff16d
commit
4a8dc7d151
9 files changed
+411
-5
No files matched your search
@@ -101,6 +101,8 @@ unreachable, and search runs against your own database rather than someone else'
|
||||
| **Threaded conversations** — replies collapse into one conversation, newest expanded. | **Compose** — rich text, contact autocomplete, send from any account or alias. |
|
||||
|  |  |
|
||||
| **Dark mode** — follows your system preference, or pick one and stick with it. | **Settings** — add accounts, reorder them, choose how much history to sync. |
|
||||
|  |  |
|
||||
| **Filters** — conditions as a tree, restated in plain English, counted against real mail before you save. | **Calendar** — beside the mail on a wide screen, its own page below that. |
|
||||
|
||||
<sub>Screenshots use demo data — none of the accounts, senders or messages shown are real.</sub>
|
||||
|
||||
|
||||
@@ -416,8 +416,10 @@ export default class extends Controller {
|
||||
|
||||
this.summaryTarget.textContent = data.description ?? ""
|
||||
this.countTarget.dataset.state = "ok"
|
||||
this.countTarget.textContent = (data.capped ? this._t("count.capped") : this._t("count.exact"))
|
||||
.replace("%count%", String(data.count))
|
||||
this.countTarget.textContent = this._plural(
|
||||
this._t(data.capped ? "count.capped" : "count.exact"),
|
||||
data.count,
|
||||
).replace("%count%", String(data.count))
|
||||
} catch {
|
||||
// A failed probe is only a missing hint — never block the save.
|
||||
this.countTarget.dataset.state = "error"
|
||||
@@ -544,4 +546,19 @@ export default class extends Controller {
|
||||
_t(key) {
|
||||
return this.i18nValue[key] ?? key
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the singular or plural half of a "one|many" string.
|
||||
*
|
||||
* The count changes as the rule is typed and the number is substituted
|
||||
* here rather than fetched, so the translation has to arrive carrying both
|
||||
* shapes — hence the pipe, which is Symfony's own separator. A string
|
||||
* without one is returned untouched, so a translation that has not been
|
||||
* split yet still reads.
|
||||
*/
|
||||
_plural(message, count) {
|
||||
const forms = message.split("|")
|
||||
|
||||
return forms.length < 2 ? message : (count === 1 ? forms[0] : forms[1])
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command\Test;
|
||||
|
||||
use App\Jmap\State\JmapObjectType;
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
use App\Domain\Enum\Mail\ThreadingMethod;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Jmap\State\StateManager;
|
||||
use App\Repository\Calendar\CalendarEventRepository;
|
||||
use App\Repository\Mail\AccountRepository;
|
||||
use App\Repository\Mail\MessageThreadRepository;
|
||||
use App\Repository\User\UserRepository;
|
||||
use App\Service\Label\LabelResolver;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* A mailbox worth photographing.
|
||||
*
|
||||
* The README's tour is captured by tests/e2e/screenshots.spec.ts, which used to
|
||||
* need a demo installation nobody else had: the E2E fixtures seed "E2E Trash
|
||||
* Me", which is the right name for an assertion and the wrong one for a picture
|
||||
* in a readme. So the screenshots could only be retaken by the one person
|
||||
* holding that mailbox, and only they could tell whether they still matched the
|
||||
* app.
|
||||
*
|
||||
* This seeds mail that reads like mail — senders, subjects, labels and dates a
|
||||
* screenshot can show without a caption apologising for it — and nothing in it
|
||||
* is anyone's real correspondence, which is what the caption under those images
|
||||
* promises.
|
||||
*
|
||||
* Not for prod, and it says so. Everything it writes belongs to one account it
|
||||
* owns outright and wipes on every run, so it cannot touch mail that was synced.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:test:seed-demo',
|
||||
description: 'Seed a believable demo mailbox for the README screenshots',
|
||||
)]
|
||||
final class SeedDemoMailboxCommand extends Command
|
||||
{
|
||||
use TargetsTestUser;
|
||||
|
||||
private const string ACCOUNT_USERNAME = 'demo@plmail.test';
|
||||
|
||||
/** Custom labels the demo mailbox files things under. */
|
||||
private const array LABELS = ['Receipts', 'Travel', 'Family'];
|
||||
|
||||
/**
|
||||
* The mailbox itself: subject, sender name, sender address, label, unread,
|
||||
* hours ago, body.
|
||||
*
|
||||
* Ordinary, dull and plausible on purpose. A screenshot of a mailbox full
|
||||
* of exciting subjects reads as a mock-up; this reads as Tuesday.
|
||||
*
|
||||
* @var list<array{string, string, string, string|null, bool, int, string}>
|
||||
*/
|
||||
private const array THREADS = [
|
||||
['Bookshelf dimensions', 'Priya Raman', 'priya.raman@example.com', null, true, 1,
|
||||
"The alcove is 182cm from skirting to the underside of the shelf, and 96cm wide.\n\n"
|
||||
. 'The 175cm unit would leave a finger of daylight at the top, which I think looks '
|
||||
. "deliberate. Photos when the light is better.\n\nP",
|
||||
],
|
||||
['Your order has shipped', 'Kleinschmidt Werkzeug', 'versand@kleinschmidt.example', 'Receipts', true, 3,
|
||||
"Two of three items are on their way and should arrive Thursday.\n\n"
|
||||
. 'The chisel set is back-ordered until the 19th; nothing to do at your end.',
|
||||
],
|
||||
['Re: Sunday', 'Mum', 'ruth@example.org', 'Family', false, 6,
|
||||
"Sunday works. Bring nothing, there is far too much food already.\n\n"
|
||||
. 'Your father has opinions about the hedge again.',
|
||||
],
|
||||
['Booking confirmed — Hotel Aare, 12–14 Sep', 'Hotel Aare', 'reservations@hotelaare.example', 'Travel', true, 9,
|
||||
"Room 214, two nights, breakfast included. Arrival any time after 15:00.\n\n"
|
||||
. 'Reply to this message if the train puts you in after 22:00 and we will leave the key.',
|
||||
],
|
||||
['Invoice 2026-0841', 'Steuerbüro Lang', 'buchhaltung@lang-steuer.example', 'Receipts', false, 26,
|
||||
"Attached is the invoice for the quarter, due in 14 days.\n\n"
|
||||
. 'Nothing needs signing.',
|
||||
],
|
||||
['Rail replacement on the 12th', 'SBB Kundeninfo', 'info@sbb.example', 'Travel', false, 30,
|
||||
"The 07:41 does not run on the 12th. The 07:19 is replaced by a bus as far as Olten "
|
||||
. "and adds 25 minutes.\n\nNo action needed if you are travelling later.",
|
||||
],
|
||||
['Photos from the weekend', 'Jonas Weber', 'jonas.weber@example.com', 'Family', false, 52,
|
||||
"Forty of them, and about six are any good. The one of the dog in the river is the "
|
||||
. "only one worth printing.\n\nJ",
|
||||
],
|
||||
['Reading group — October pick', 'Sofia Marchetti', 'sofia@example.net', null, false, 74,
|
||||
"We landed on the short one, thankfully. Meeting moves to the 8th because the room "
|
||||
. "is taken on the 1st.\n\nSame time, same place, bring the good chairs back.",
|
||||
],
|
||||
['Meter reading due', 'Stadtwerke', 'ablesung@stadtwerke.example', null, false, 99,
|
||||
"Please submit your reading before the 20th. It takes a minute online and saves an "
|
||||
. 'estimate you will only have to correct later.',
|
||||
],
|
||||
['Re: quote for the trim', 'Marek Nowak', 'marek@nowak-schreinerei.example', null, false, 120,
|
||||
"Oak trim to match is fine, and I can do it in the same run as the shelves.\n\n"
|
||||
. 'Adds about a week. Say the word and I will order the timber.',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly AccountRepository $accountRepository,
|
||||
private readonly CalendarEventRepository $calendarEventRepository,
|
||||
private readonly MessageThreadRepository $threadRepository,
|
||||
private readonly LabelResolver $labelResolver,
|
||||
private readonly StateManager $stateManager,
|
||||
#[Autowire('%kernel.environment%')]
|
||||
private readonly string $environment,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->configureUserOption();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
if ('prod' === $this->environment) {
|
||||
$io->error('app:test:seed-demo must not run in the prod environment.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$userEmail = $this->resolveUserEmail($input);
|
||||
$user = $this->userRepository->findOneBy(['email' => $userEmail]);
|
||||
|
||||
if (null === $user) {
|
||||
$io->error(sprintf('User "%s" not found — run app:test:seed-user first.', $userEmail));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$account = $this->accountRepository->findOneBy([
|
||||
'usr' => $user,
|
||||
'username' => self::ACCOUNT_USERNAME,
|
||||
]);
|
||||
|
||||
if (null === $account) {
|
||||
$account = new Account();
|
||||
$account->usr = $user;
|
||||
$account->name = 'Demo';
|
||||
$account->email = 'you@example.com';
|
||||
$account->username = self::ACCOUNT_USERNAME;
|
||||
$account->imapHost = 'imap.example.com';
|
||||
$account->imapPort = 993;
|
||||
$account->imapEncryption = 'ssl';
|
||||
$account->authType = 'password';
|
||||
$account->isActive = true;
|
||||
|
||||
$this->entityManager->persist($account);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
// The calendar goes too. The screenshot suite creates its events
|
||||
// through the dialog on every run, so anything already there is a
|
||||
// leftover from the last one and would show up twice.
|
||||
foreach ($this->calendarEventRepository->findBy(['usr' => $user]) as $event) {
|
||||
$this->entityManager->remove($event);
|
||||
}
|
||||
|
||||
// The demo mailbox ends up the only one, because the sidebar lists
|
||||
// every account and "E2E Mailbox" in a readme screenshot gives the
|
||||
// game away. Safe here and nowhere else: this command already refuses
|
||||
// to run in prod, and in dev or test the other accounts are fixtures.
|
||||
foreach ($this->accountRepository->findBy(['usr' => $user]) as $other) {
|
||||
if ($other->username !== self::ACCOUNT_USERNAME) {
|
||||
$this->entityManager->remove($other);
|
||||
}
|
||||
}
|
||||
|
||||
// Wiped and rewritten each run, so the same command twice gives the
|
||||
// same mailbox — a screenshot suite that accumulates threads shows a
|
||||
// different inbox every time it is run.
|
||||
foreach ($this->threadRepository->findBy(['account' => $account]) as $thread) {
|
||||
$this->entityManager->remove($thread);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$inbox = $this->labelResolver->systemLabel(LabelRole::Inbox, $account);
|
||||
$labels = [];
|
||||
|
||||
foreach (self::LABELS as $name) {
|
||||
$labels[$name] = $this->labelResolver->customChain([$name], $account);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$now = new DateTimeImmutable();
|
||||
$messages = [];
|
||||
|
||||
foreach (self::THREADS as [$subject, $fromName, $fromAddress, $label, $unread, $hoursAgo, $body]) {
|
||||
$receivedAt = $now->modify(sprintf('-%d hours', $hoursAgo));
|
||||
|
||||
$message = new Message();
|
||||
$message->account = $account;
|
||||
$message->subject = $subject;
|
||||
$message->fromName = $fromName;
|
||||
$message->fromAddress = $fromAddress;
|
||||
$message->toAddresses = [['name' => 'You', 'address' => 'you@example.com']];
|
||||
$message->bodyText = $body;
|
||||
$message->receivedAt = $receivedAt;
|
||||
$message->sentAt = $receivedAt;
|
||||
$message->hasAttachments = false;
|
||||
$message->flags = [];
|
||||
$message->syncedAt = $now;
|
||||
$message->addLabel($inbox);
|
||||
|
||||
if (false === $unread) {
|
||||
$message->seenAt = $receivedAt;
|
||||
}
|
||||
|
||||
$thread = new MessageThread();
|
||||
$thread->account = $account;
|
||||
$thread->subject = $subject;
|
||||
$thread->normalizedSubject = mb_strtolower(trim($subject));
|
||||
$thread->threadingMethod = ThreadingMethod::SubjectFallback;
|
||||
$thread->messageCount = 1;
|
||||
$thread->unreadCount = true === $unread ? 1 : 0;
|
||||
$thread->category = MessageCategory::Primary;
|
||||
$thread->attachmentCount = 0;
|
||||
$thread->lastMessageAt = $receivedAt;
|
||||
$thread->addLabel($inbox);
|
||||
|
||||
if (null !== $label) {
|
||||
$message->addLabel($labels[$label]);
|
||||
$thread->addLabel($labels[$label]);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($message);
|
||||
$this->entityManager->persist($thread);
|
||||
|
||||
$message->thread = $thread;
|
||||
$messages[] = $message;
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Seeded mail is real mail to a JMAP client — see the same note in
|
||||
// SeedTestEmailCommand. Recorded after the flush, because the log rows
|
||||
// need the ids it mints.
|
||||
$accountId = (int) $account->id;
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$this->stateManager->recordCreated($accountId, JmapObjectType::Email, (string) $message->id);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$io->success(sprintf(
|
||||
'Seeded %d demo threads and %d labels for %s.',
|
||||
count($messages),
|
||||
count($labels),
|
||||
$userEmail,
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "./support/test";
|
||||
import { seed } from "./support/config";
|
||||
|
||||
/**
|
||||
* Captures the README screenshots against the demo mailbox.
|
||||
@@ -24,6 +25,12 @@ test.describe("README screenshots", () => {
|
||||
'Demo mailbox required — run "npm run test:e2e:screenshots".',
|
||||
);
|
||||
|
||||
// The mailbox in the pictures. app:test:seed-demo writes senders, subjects
|
||||
// and labels that can be shown without a caption apologising for them —
|
||||
// the regression fixtures ("E2E Trash Me") cannot, which is why this suite
|
||||
// needed an installation nobody else had.
|
||||
test.beforeAll(() => seed("seed-demo"));
|
||||
|
||||
test("inbox", async ({ page }) => {
|
||||
await page.goto("/mail/inbox");
|
||||
await expect(page.locator("#message-list li").first()).toBeVisible();
|
||||
@@ -88,4 +95,104 @@ test.describe("README screenshots", () => {
|
||||
await page.waitForTimeout(1200);
|
||||
await page.screenshot({ path: `${OUT}/admin.png` });
|
||||
});
|
||||
|
||||
/**
|
||||
* The filter editor mid-build, because an empty one shows the chrome and
|
||||
* not the point: the sentence underneath and the live count are what the
|
||||
* screen is for, and neither exists until there is a rule to describe.
|
||||
*/
|
||||
test("filters", async ({ page }) => {
|
||||
// Reached through the list, not by URL: the editor renders into a
|
||||
// turbo-frame on the settings page and is a fragment on its own.
|
||||
await page.goto("/settings?section=filters");
|
||||
await page.locator("#settings-filter-list")
|
||||
.getByRole("link", { name: "New filter" })
|
||||
.click();
|
||||
|
||||
const editor = page.locator("#filter-editor");
|
||||
await expect(editor).toBeVisible();
|
||||
|
||||
await editor.getByLabel("Name").fill("File the receipts");
|
||||
|
||||
const tree = editor.locator('[data-rules--rule-builder-target="tree"]');
|
||||
await tree.locator("select").first().selectOption("from");
|
||||
await tree.locator('input[type="text"]').first().fill("versand@");
|
||||
|
||||
await editor.getByRole("button", { name: "Add condition" }).first().click();
|
||||
await tree.locator("select").nth(1).selectOption("subject");
|
||||
await tree.locator('input[type="text"]').nth(1).fill("order");
|
||||
|
||||
await editor.getByRole("button", { name: "Add action" }).click();
|
||||
|
||||
// File them somewhere that matches the rule's name — a screenshot
|
||||
// where the two disagree is one the reader has to stop and re-read.
|
||||
const action = editor.locator('[data-rules--rule-builder-target="actionList"] select').nth(1);
|
||||
await action.selectOption({ label: "Receipts" });
|
||||
|
||||
// The sentence and the count are the point of the screen, and both
|
||||
// come back from the preview endpoint.
|
||||
const summary = editor.locator('[data-rules--rule-builder-target="summary"]');
|
||||
await expect(summary).toContainText("If");
|
||||
await expect(
|
||||
editor.locator('[data-rules--rule-builder-target="count"][data-state="ok"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Scrolled to, or the rule reads as a form with the answer cut off:
|
||||
// the editor is taller than the viewport and both readouts sit under
|
||||
// the fold.
|
||||
await summary.scrollIntoViewIfNeeded();
|
||||
await page.mouse.move(900, 600);
|
||||
await page.mouse.wheel(0, 160);
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: `${OUT}/filters.png` });
|
||||
});
|
||||
|
||||
/**
|
||||
* The week with something in it. Created through the dialog rather than
|
||||
* seeded: an event is a JSCalendar object whose occurrences are
|
||||
* materialised on write, and going around the writer to put rows in the
|
||||
* database is how a screenshot ends up showing a state the app cannot
|
||||
* produce.
|
||||
*/
|
||||
test("calendar", async ({ page }) => {
|
||||
// Spread across the week, because every event landing on one day says
|
||||
// nothing about a week view. Dates are relative to Monday of the
|
||||
// current week, so the picture is the same shape whenever it is taken.
|
||||
const monday = new Date();
|
||||
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
||||
|
||||
const at = (dayOffset: number, time: string): string => {
|
||||
const day = new Date(monday);
|
||||
day.setDate(day.getDate() + dayOffset);
|
||||
|
||||
return `${day.toISOString().slice(0, 10)}T${time}`;
|
||||
};
|
||||
|
||||
const events: Array<[string, string, string]> = [
|
||||
["Standup", at(0, "09:00"), at(0, "09:15")],
|
||||
["Lunch with Priya", at(1, "12:30"), at(1, "13:30")],
|
||||
["Bookshelf delivery", at(2, "08:00"), at(2, "12:00")],
|
||||
["Dentist", at(3, "16:15"), at(3, "17:00")],
|
||||
["Reading group", at(4, "19:00"), at(4, "21:00")],
|
||||
];
|
||||
|
||||
await page.goto("/calendar/week");
|
||||
|
||||
for (const [title, startsAt, endsAt] of events) {
|
||||
await page.getByRole("button", { name: "New event", exact: true }).click();
|
||||
|
||||
const modal = page.locator("#modal-backdrop");
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await modal.locator("#event-title").fill(title);
|
||||
await modal.locator("#event-starts").fill(startsAt);
|
||||
await modal.locator("#event-ends").fill(endsAt);
|
||||
|
||||
await modal.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByRole("button", { name: new RegExp(title) }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: `${OUT}/calendar.png` });
|
||||
});
|
||||
});
|
||||
@@ -1020,7 +1020,7 @@ settings:
|
||||
count: '%count% Filter'
|
||||
count_capped: 'Trifft auf %count%+ vorhandene Nachrichten zu'
|
||||
count_error: Noch kein verwendbarer Filter
|
||||
count_exact: 'Trifft auf %count% vorhandene Nachrichten zu'
|
||||
count_exact: 'Trifft auf %count% vorhandene Nachricht zu|Trifft auf %count% vorhandene Nachrichten zu'
|
||||
delete: Filter "%name%" löschen
|
||||
delete_confirm: 'Den Filter "%name%" löschen? Bereits einsortierte Mail bleibt, wo sie ist.'
|
||||
delete_title: Filter löschen
|
||||
|
||||
@@ -1004,7 +1004,10 @@ settings:
|
||||
count: '%count% filters'
|
||||
count_capped: 'Matches %count%+ existing messages'
|
||||
count_error: Not a usable filter yet
|
||||
count_exact: 'Matches %count% existing messages'
|
||||
# Pluralised in the browser, which is where the number changes —
|
||||
# the builder swaps %count% in without another round trip, so the
|
||||
# string has to carry both shapes. See rule_builder_controller.
|
||||
count_exact: 'Matches %count% existing message|Matches %count% existing messages'
|
||||
delete: Delete filter "%name%"
|
||||
delete_confirm: 'Delete the filter "%name%"? Mail it already sorted stays where it is.'
|
||||
delete_title: Delete filter
|
||||
|
||||
@@ -860,7 +860,7 @@ settings:
|
||||
count: '%count% sieves'
|
||||
count_capped: 'Catches %count%+ yarns already aboard'
|
||||
count_error: Not a workable sieve yet
|
||||
count_exact: 'Catches %count% yarns already aboard'
|
||||
count_exact: 'Catches %count% yarn already aboard|Catches %count% yarns already aboard'
|
||||
delete: Scuttle the sieve "%name%"
|
||||
delete_confirm: 'Scuttle the sieve "%name%"? Yarns it already sorted stay put.'
|
||||
delete_title: Scuttle sieve
|
||||
|
||||
Reference in new issue
Block a user