AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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("")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user