AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
14 changed files with 428 additions and 21 deletions
Showing only changes of commit ea831d1857 - Show all commits
@@ -0,0 +1,31 @@
package com.fabledsword.inkwell
import android.content.Context
/**
* When each tag's lens was last opened on this phone, for the drawer's recent tags
* (#5931). Opening a tag counts as using it, alongside the notes that carry it.
*
* Kept on the device rather than synced: it is a convenience about where this
* person's thumb goes on this screen, and losing it costs one tap.
*/
class TagViews(
context: Context,
) {
// applicationContext, as in SyncSettings: this must not hold an Activity.
private val prefs =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
fun all(): Map<String, Long> = prefs.all.mapNotNull { (id, at) -> (at as? Long)?.let { id to it } }.toMap()
/** Records a view now and returns every view, this one included. */
fun record(tagId: String): Map<String, Long> {
val now = System.currentTimeMillis()
prefs.edit().putLong(tagId, now).apply()
return all() + (tagId to now)
}
private companion object {
const val FILE = "inkwell-tag-views"
}
}
@@ -60,6 +60,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -68,11 +69,13 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.TagViews
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import kotlinx.coroutines.launch
@@ -96,6 +99,15 @@ fun BoardScreen(
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// Opening a tag's lens counts as using it, however it was opened: from the
// drawer, or a tag on a card (#5931).
val context = LocalContext.current
val tagViews = remember(context) { TagViews(context) }
var views by remember { mutableStateOf(tagViews.all()) }
LaunchedEffect(state.destination) {
(state.destination as? Destination.WithLabel)?.let { views = tagViews.record(it.id) }
}
val snackbars = remember { SnackbarHostState() }
// Held HERE rather than on the card. A card lives in a lazy grid and is disposed
@@ -143,6 +155,7 @@ fun BoardScreen(
NavigationDrawer(
current = state.destination,
labels = state.labels,
recency = tagRecency(state.labels, state.notes, views),
syncSummary = sync.summary,
onOpen = {
onOpen(it)
@@ -401,11 +414,16 @@ private fun SearchBar(
private fun NavigationDrawer(
current: Destination,
labels: List<Label>,
recency: Map<String, Long>,
syncSummary: String?,
onOpen: (Destination) -> Unit,
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
) {
// Every tag, once asked for; until then the few in use (#5931).
var allTags by rememberSaveable { mutableStateOf(false) }
val shown = if (allTags) labels else recentTags(labels, { recency[it.id] ?: 0L }, { it.name })
ModalDrawerSheet {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
@@ -418,6 +436,27 @@ private fun NavigationDrawer(
DrawerRow(destination, current, onOpen)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
DrawerRow(destination, current, onOpen)
}
// Sync sits below the divider with the destinations rather than behind
// a settings gear: it is not a preference, it is where you go to find
// out whether this phone and your desktop are actually in step. The
// subtitle answers that without opening it.
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.sync_title)) },
badge = syncSummary?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
selected = false,
onClick = onOpenSync,
modifier = Modifier.padding(horizontal = 12.dp),
)
// Tags last, as on the web (#5915): it is the one list here that grows
// without limit, so everything with a fixed place sits above it.
//
// The header renders even with no tags, unlike the rows below it: the
// manage screen is where you go to MAKE the first one, and hiding the
// way in until one exists would be a door that appears only once you
@@ -445,27 +484,28 @@ private fun NavigationDrawer(
)
}
}
labels.forEach { label ->
shown.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
DrawerRow(destination, current, onOpen)
if (labels.size > RECENT_TAG_COUNT) {
NavigationDrawerItem(
label = {
Text(
text =
if (allTags) {
stringResource(R.string.tags_show_fewer)
} else {
stringResource(R.string.tags_show_all, labels.size)
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
selected = false,
onClick = { allTags = !allTags },
modifier = Modifier.padding(horizontal = 12.dp),
)
}
// Sync sits below the divider with the destinations rather than behind
// a settings gear: it is not a preference, it is where you go to find
// out whether this phone and your desktop are actually in step. The
// subtitle answers that without opening it.
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.sync_title)) },
badge = syncSummary?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
selected = false,
onClick = onOpenSync,
modifier = Modifier.padding(horizontal = 12.dp),
)
}
}
}
@@ -0,0 +1,52 @@
package com.fabledsword.inkwell.ui
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import java.time.OffsetDateTime
/**
* The drawer lists the few tags in use rather than every tag (#5931), so a long
* list never pushes Archive, Trash and Sync out of reach. Mirrors
* `frontend/src/notes/recentTags.ts`.
*/
const val RECENT_TAG_COUNT = 5
/**
* The [count] most recent of [tags], listed by name so a row does not move under
* the thumb that tapped it. Every tag while there are no more than [count].
*/
fun <T> recentTags(
tags: List<T>,
latest: (T) -> Long,
name: (T) -> String,
count: Int = RECENT_TAG_COUNT,
): List<T> {
if (tags.size <= count) return tags
return tags
.sortedWith(compareByDescending(latest).thenBy(String.CASE_INSENSITIVE_ORDER, name))
.take(count)
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, name))
}
/**
* How recently each tag was used or viewed, in epoch milliseconds: the later of its
* newest note in the store ([Label.lastUsed]), its newest note on the board now —
* so a tag applied a moment ago counts before the labels are next listed — and the
* last time its lens was opened here ([views]).
*/
fun tagRecency(
labels: List<Label>,
notes: List<Note>,
views: Map<String, Long>,
): Map<String, Long> {
val latest = labels.associate { it.id to maxOf(epochMillis(it.lastUsed), views[it.id] ?: 0L) }.toMutableMap()
for (note in notes) {
val at = epochMillis(note.updatedAt)
for (label in note.labels) latest.computeIfPresent(label.id) { _, was -> maxOf(was, at) }
}
return latest
}
/** 0 for a missing or unreadable stamp: such a tag simply ranks last. */
fun epochMillis(stamp: String?): Long =
stamp?.let { runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull() } ?: 0L
@@ -133,6 +133,8 @@
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
schema still says Label, and no string here needs to know that. -->
<string name="tags_manage">Manage tags</string>
<string name="tags_show_all">All tags (%1$d)</string>
<string name="tags_show_fewer">Fewer tags</string>
<string name="tags_title">Tags</string>
<string name="tags_back">Back</string>
<string name="tags_new_hint">New tag</string>
@@ -0,0 +1,41 @@
package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
/** The same cases as `frontend/src/notes/recentTags.test.ts`. */
class RecentTagsTest {
private data class Tag(
val name: String,
val at: Long,
)
private fun recent(tags: List<Tag>) = recentTags(tags, { it.at }, { it.name }).map { it.name }
@Test
fun `shows every tag while there are no more than five`() {
val tags = listOf(Tag("c", 0), Tag("a", 0), Tag("b", 0))
assertSame(tags, recentTags(tags, { it.at }, { it.name }))
}
@Test
fun `keeps the five used most recently, listed by name`() {
val tags = listOf("a", "b", "c", "d", "e", "f", "g").mapIndexed { i, n -> Tag(n, i.toLong()) }
assertEquals(listOf("c", "d", "e", "f", "g"), recent(tags))
}
@Test
fun `fills with tags never used, by name, when fewer than five have been`() {
val tags = listOf(Tag("f", 10), Tag("e", 0), Tag("d", 0), Tag("c", 0), Tag("B", 0), Tag("a", 0))
assertEquals(listOf("a", "B", "c", "d", "f"), recent(tags))
}
@Test
fun `reads both stamp forms the store holds`() {
assertEquals(1_760_000_000_000L, epochMillis("2025-10-09T08:53:20.000Z"))
assertEquals(1_760_000_000_000L, epochMillis("2025-10-09T08:53:20+00:00"))
assertEquals(0L, epochMillis(null))
assertEquals(0L, epochMillis("not a time"))
}
}
+5
View File
@@ -334,6 +334,9 @@ pub struct Label {
/// How many notes carry it. Only populated in listings — `None` elsewhere,
/// matching the REST single-label responses.
pub count: Option<i64>,
/// The newest edit among the untrashed notes carrying it, as the store writes
/// timestamps; None when nothing does. Ranks the drawer's recent tags (#5931).
pub last_used: Option<String>,
}
impl From<core_models::Label> for Label {
@@ -343,12 +346,14 @@ impl From<core_models::Label> for Label {
name,
color,
count,
last_used,
} = value;
Label {
id,
name,
color,
count,
last_used,
}
}
}
+3
View File
@@ -118,6 +118,9 @@ pub struct Label {
// preserves the count it already holds (matches the REST single-label responses).
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<i64>,
/// The newest `updated_at` among the untrashed notes carrying it, or None when
/// nothing does. The drawer keeps the few most recent tags in reach (#5931).
pub last_used: Option<String>,
}
#[derive(Serialize)]
+35
View File
@@ -992,6 +992,8 @@ pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::
/// A label with how many live notes carry it; `label_row` reads it.
const LABEL_SELECT: &str = "SELECT l.id, l.name, l.color,
(SELECT COUNT(*) FROM note_labels nl JOIN notes n ON n.id = nl.note_id
WHERE nl.label_id = l.id AND n.trashed = 0),
(SELECT MAX(n.updated_at) FROM note_labels nl JOIN notes n ON n.id = nl.note_id
WHERE nl.label_id = l.id AND n.trashed = 0)
FROM labels l";
@@ -1001,6 +1003,7 @@ fn label_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Label> {
name: r.get(1)?,
color: r.get(2)?,
count: Some(r.get(3)?),
last_used: r.get(4)?,
})
}
@@ -1799,6 +1802,38 @@ mod tests {
assert_eq!(list_labels(&conn).expect("labels")[0].count, Some(1));
}
#[test]
fn a_label_is_as_recent_as_its_newest_untrashed_note() {
let conn = db();
let used = create_label(&conn, "errands").expect("label");
create_label(&conn, "someday").expect("label");
let old = create_label(&conn, "old").expect("label");
let kept = note(&conn, "milk");
let binned = note(&conn, "stale");
set_labels(&conn, &kept.id, std::slice::from_ref(&used.id)).expect("labels");
set_labels(&conn, &binned.id, std::slice::from_ref(&old.id)).expect("labels");
trash(&conn, &binned.id).expect("trash");
let kept = get_note(&conn, &kept.id).expect("get");
let by_name = |name: &str| {
list_labels(&conn)
.expect("labels")
.into_iter()
.find(|l| l.name == name)
.expect("listed")
};
assert_eq!(
by_name("errands").last_used.as_deref(),
kept.updated_at.as_deref()
);
assert_eq!(by_name("someday").last_used, None, "carried by nothing");
assert_eq!(
by_name("old").last_used,
None,
"a trashed note does not count"
);
}
fn tombstones(conn: &Connection) -> Vec<(String, String)> {
let mut stmt = conn
.prepare("SELECT entity, id FROM pending_deletes ORDER BY entity, id")
+38 -2
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config";
import { useLabelsStore } from "../stores/labels";
import { useNotesStore } from "../stores/notes";
import { useReminderStore } from "../stores/reminders";
import { useUiStore } from "../stores/ui";
import BaseModal from "./BaseModal.vue";
@@ -16,12 +17,20 @@ import ShareDialog from "./ShareDialog.vue";
import { isDesktop } from "../desktop/bridge";
import { useLensName } from "../composables/useLensName";
import { labelDotClasses } from "../notes/colors";
import {
RECENT_TAG_COUNT,
loadTagViews,
recentTags,
recordTagView,
type TagViews,
} from "../notes/recentTags";
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const config = useConfigStore();
const labels = useLabelsStore();
const notes = useNotesStore();
const reminders = useReminderStore();
const ui = useUiStore();
// Sync is a desktop-app concern: the web build already IS the server's UI.
@@ -45,6 +54,23 @@ const buildLabel = computed(
);
const managing = ref(false);
// The rail lists the few tags in use, not all of them (#5931); "All tags" opens the
// rest in place, below everything else, so nothing is pushed out of view.
const viewer = computed(() => session.user?.id ?? "local");
const tagViews = ref<TagViews>({});
const allTags = ref(false);
watch(viewer, (id) => (tagViews.value = loadTagViews(id)), { immediate: true });
watch(
() => (route.name === "label" ? String(route.params.id) : null),
(id) => {
if (id) tagViews.value = recordTagView(viewer.value, tagViews.value, id);
},
{ immediate: true },
);
const railTags = computed(() =>
allTags.value ? labels.items : recentTags(labels.items, notes.items, tagViews.value),
);
const showShortcuts = ref(false);
const paletteOpen = ref(false);
const drawer = ref(false);
@@ -419,7 +445,8 @@ async function signOut() {
<ImportNotes />
<!-- Tags last: it is the one list here that grows without limit, so
everything with a fixed place sits above it and stays put. -->
everything with a fixed place sits above it and stays put. And it
shows only the few tags in use (#5931), the rest one press away. -->
<div class="mt-3 flex items-center justify-between px-3 pb-1">
<span class="section-label">Tags</span>
<button
@@ -434,7 +461,7 @@ async function signOut() {
</div>
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No tags yet</p>
<RouterLink
v-for="lb in labels.items"
v-for="lb in railTags"
:key="lb.id"
:to="`/label/${lb.id}`"
class="nav-link"
@@ -446,6 +473,15 @@ async function signOut() {
></span>
<span class="truncate">{{ lb.name }}</span>
</RouterLink>
<button
v-if="labels.items.length > RECENT_TAG_COUNT"
type="button"
class="nav-link text-xs text-neutral-500 dark:text-neutral-400"
:aria-expanded="allTags"
@click.stop="allTags = !allTags"
>
{{ allTags ? "Fewer tags" : `All tags (${labels.items.length})` }}
</button>
<!-- Account, settings and sign-out, for the screens where they are NOT in
the header. Four icons plus a search field never fit one phone-width
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { recentTags } from "./recentTags";
const tag = (id: string, last_used: string | null = null) => ({ id, name: id, last_used });
const names = (tags: { name: string }[]) => tags.map((t) => t.name);
describe("the rail's recent tags", () => {
it("shows every tag while there are no more than five", () => {
const tags = ["c", "a", "b"].map((id) => tag(id));
expect(recentTags(tags, [], {})).toBe(tags);
});
it("keeps the five used most recently, listed by name", () => {
const tags = ["a", "b", "c", "d", "e", "f", "g"].map((id, i) =>
tag(id, `2026-10-0${i + 1}T00:00:00Z`),
);
expect(names(recentTags(tags, [], {}))).toEqual(["c", "d", "e", "f", "g"]);
});
it("counts opening a tag as using it", () => {
const tags = ["a", "b", "c", "d", "e", "f"].map((id) => tag(id, "2026-10-02T00:00:00Z"));
tags[0].last_used = "2026-01-01T00:00:00Z";
const viewed = { a: Date.parse("2026-10-10T00:00:00Z") };
expect(names(recentTags(tags, [], viewed))).toContain("a");
});
it("counts a note edited since the listing was fetched", () => {
const tags = ["a", "b", "c", "d", "e", "f"].map((id) => tag(id, "2026-10-02T00:00:00Z"));
tags[0].last_used = null;
const notes = [{ labels: [{ id: "a" }], updated_at: "2026-10-12T00:00:00+00:00" }];
expect(names(recentTags(tags, notes, {}))).toContain("a");
});
it("fills with tags never used, by name, when fewer than five have been", () => {
const tags = ["f", "e", "d", "c", "b", "a"].map((id) => tag(id));
tags[0].last_used = "2026-10-02T00:00:00Z";
expect(names(recentTags(tags, [], {}))).toEqual(["a", "b", "c", "d", "f"]);
});
});
+75
View File
@@ -0,0 +1,75 @@
// The rail keeps only the few tags you are using, so a long tag list never pushes
// anything out of view (#5931). A tag is as recent as the later of:
// - its last use: the newest note carrying it, from the listing (`last_used`), and
// from the notes already loaded, so a tag applied a moment ago counts before the
// listing is next fetched;
// - its last view: opening its lens, remembered on this device for this user.
// The few shown are listed by name, not by recency, so a row does not move under
// the pointer when it is clicked.
export const RECENT_TAG_COUNT = 5;
interface Tag {
id: string;
name: string;
last_used?: string | null;
}
interface TaggedNote {
labels: { id: string }[];
updated_at: string | null;
}
export type TagViews = Record<string, number>;
function ms(when: string | null | undefined): number {
const t = when ? Date.parse(when) : NaN;
return Number.isNaN(t) ? 0 : t;
}
export function recentTags<T extends Tag>(
tags: T[],
notes: TaggedNote[],
views: TagViews,
count = RECENT_TAG_COUNT,
): T[] {
if (tags.length <= count) return tags;
const latest = new Map<string, number>();
const bump = (id: string, t: number) => latest.set(id, Math.max(latest.get(id) ?? 0, t));
for (const tag of tags) {
bump(tag.id, ms(tag.last_used));
bump(tag.id, views[tag.id] ?? 0);
}
for (const note of notes) {
const t = ms(note.updated_at);
for (const lb of note.labels) if (latest.has(lb.id)) bump(lb.id, t);
}
return [...tags]
.sort((a, b) => (latest.get(b.id) ?? 0) - (latest.get(a.id) ?? 0) || a.name.localeCompare(b.name))
.slice(0, count)
.sort((a, b) => a.name.localeCompare(b.name));
}
// Views live in this browser, one record per user so a shared machine keeps each
// person's own. Storage can be missing or refuse (private windows); the rail then
// ranks by use alone.
const VIEWS_KEY = "inkwell.tag-views.";
export function loadTagViews(userId: string): TagViews {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(VIEWS_KEY + userId) ?? "{}");
return parsed && typeof parsed === "object" ? (parsed as TagViews) : {};
} catch {
return {};
}
}
export function recordTagView(userId: string, views: TagViews, tagId: string, at = Date.now()): TagViews {
const next = { ...views, [tagId]: at };
try {
localStorage.setItem(VIEWS_KEY + userId, JSON.stringify(next));
} catch {
// Kept for this session only.
}
return next;
}
+3
View File
@@ -9,6 +9,9 @@ export interface Label {
color: string;
// Number of notes carrying this label (from GET /api/labels; used in label management).
count?: number;
// The newest edit among the untrashed notes carrying it, listings only. Ranks the
// rail's recent tags (#5931).
last_used?: string | null;
}
export const useLabelsStore = defineStore("labels", () => {
+25 -1
View File
@@ -6,8 +6,10 @@ from sqlalchemy import func, select
from .auth import login_required
from .colors import normalize_color
from .db import session_scope
from .common import iso
from .labeling import live, named, tombstone_label
from .models.label import Label, NoteLabel
from .models.note import Note
from .responses import json_error, not_found, parse_uuid
from .serialize import serialize_label
@@ -79,7 +81,29 @@ async def list_labels():
)
).all()
) if labels else {}
return jsonify({"labels": [_serialize_label(lb, int(counts.get(lb.id, 0))) for lb in labels]})
# When each was last used: the newest edit among the notes carrying it, trash
# aside. The rail shows the few most recent (#5931), so a tag in daily use
# stays in reach however many others there are.
last_used = dict(
(
await db.execute(
select(NoteLabel.label_id, func.max(Note.updated_at))
.join(Note, Note.id == NoteLabel.note_id)
.where(
NoteLabel.label_id.in_([lb.id for lb in labels]),
Note.deleted_at.is_(None),
Note.purged_at.is_(None),
)
.group_by(NoteLabel.label_id)
)
).all()
) if labels else {}
return jsonify({
"labels": [
{**_serialize_label(lb, int(counts.get(lb.id, 0))), "last_used": iso(last_used.get(lb.id))}
for lb in labels
]
})
@bp.post("")
+20
View File
@@ -665,6 +665,26 @@ async def _push_label(app_client, lid: str, op: str, edited_at: str, name: str =
return (await resp.get_json())["results"][0]
async def test_a_tag_listing_says_when_each_was_last_used(app_client, db):
"""The rail keeps the most recent few (#5931): a tag is as recent as the newest
note carrying it, and a note in the trash does not count."""
await _signed_in(app_client, "lastused")
used = await (await app_client.post("/api/labels", json={"name": "errands"})).get_json()
idle = await (await app_client.post("/api/labels", json={"name": "someday"})).get_json()
binned = await (await app_client.post("/api/labels", json={"name": "old"})).get_json()
note = await (await app_client.post("/api/notes", json={"body": "milk"})).get_json()
await app_client.put(f"/api/notes/{note['id']}/labels", json={"label_ids": [used["id"]]})
gone = await (await app_client.post("/api/notes", json={"body": "stale"})).get_json()
await app_client.put(f"/api/notes/{gone['id']}/labels", json={"label_ids": [binned["id"]]})
assert (await app_client.post(f"/api/notes/{gone['id']}/trash")).status_code == 200
listing = {lb["id"]: lb for lb in (await (await app_client.get("/api/labels")).get_json())["labels"]}
current = (await (await app_client.get(f"/api/notes/{note['id']}")).get_json())["updated_at"]
assert listing[used["id"]]["last_used"] == current
assert listing[idle["id"]]["last_used"] is None
assert listing[binned["id"]]["last_used"] is None
async def test_a_tag_deleted_on_the_web_reaches_devices_as_a_tombstone(app_client, db):
await _signed_in(app_client, "webdelete")
tag = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()