Search and tags look through the Archive, and archived cards say so

A search or a tag filter on the main board, and a tag's own view, now
list every note not in Trash (a new `all` board view on the server and in
the core), so finished work can still be looked up. Archive and Trash
keep a search inside themselves.

An archived note met anywhere but the Archive carries an "Archived" chip
at the top of its card, on the web and on Android.

Task #6315.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 16:23:08 -04:00
co-authored by Claude Opus 5.5
parent 5780220ff1
commit b98a0c315f
10 changed files with 107 additions and 18 deletions
@@ -258,6 +258,7 @@ fun BoardScreen(
onNoteAction = onCardAction, onNoteAction = onCardAction,
onConfirmDelete = { confirmingDelete = it }, onConfirmDelete = { confirmingDelete = it },
reorderable = state.reorderable, reorderable = state.reorderable,
marksArchived = state.marksArchived,
onReorder = onReorder, onReorder = onReorder,
) )
} }
@@ -540,6 +541,8 @@ private fun NoteBoard(
onConfirmDelete: (Note) -> Unit, onConfirmDelete: (Note) -> Unit,
/** Whether cards can be dragged into a new order: the plain main board only. */ /** Whether cards can be dragged into a new order: the plain main board only. */
reorderable: Boolean, reorderable: Boolean,
/** Whether an archived card carries the Archived chip (BoardState.marksArchived). */
marksArchived: Boolean,
onReorder: (List<String>) -> Unit, onReorder: (List<String>) -> Unit,
) { ) {
// The web's breakpoints (NoteGrid.vue: three columns from `lg`, 1024px, four from // The web's breakpoints (NoteGrid.vue: three columns from `lg`, 1024px, four from
@@ -635,6 +638,7 @@ private fun NoteBoard(
onAction = { onNoteAction(note, it) }, onAction = { onNoteAction(note, it) },
onConfirmDelete = { onConfirmDelete(note) }, onConfirmDelete = { onConfirmDelete(note) },
dragging = carried, dragging = carried,
marksArchived = marksArchived,
) )
} }
DisposableEffect(note.id) { onDispose { bounds.forget(note.id) } } DisposableEffect(note.id) { onDispose { bounds.forget(note.id) } }
@@ -91,6 +91,14 @@ data class BoardState(
* filtered subset, whose order would be renumbered against notes it can't see. * filtered subset, whose order would be renumbered against notes it can't see.
*/ */
val reorderable: Boolean get() = filterable && filters.count == 0 && !searching val reorderable: Boolean get() = filterable && filters.count == 0 && !searching
/**
* Whether an archived card says so: anywhere the Archive is not the whole view. A
* search or a tag looks through the Archive as well as the board, so finished
* work can still be looked up, and the chip is what keeps it from reading as
* current.
*/
val marksArchived: Boolean get() = destination != Destination.Archive
} }
/** /**
@@ -183,15 +191,18 @@ class BoardViewModel(
): List<Note> { ): List<Note> {
// Only the main board has filters; every board view takes the search text. // Only the main board has filters; every board view takes the search text.
val searchOnly = BoardFilters().facets(text) val searchOnly = BoardFilters().facets(text)
// A search or a tag looks through the Archive too, as on the web; Archive and
// Trash keep a search inside themselves.
val notesView = if (text.isNotBlank() || filters.labelIds.isNotEmpty()) VIEW_ALL else VIEW_NOTES
return when (destination) { return when (destination) {
Destination.Notes -> core.listNotes(query(VIEW_NOTES, facets = filters.facets(text))) Destination.Notes -> core.listNotes(query(notesView, facets = filters.facets(text)))
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE, facets = searchOnly)) Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE, facets = searchOnly))
Destination.Trash -> core.listNotes(query(VIEW_TRASH, facets = searchOnly)) Destination.Trash -> core.listNotes(query(VIEW_TRASH, facets = searchOnly))
// Not a board view: the core models reminders as its own query, since // Not a board view: the core models reminders as its own query, since
// "has a reminder" cuts across archived and active alike. `search` moves // "has a reminder" cuts across archived and active alike. `search` moves
// a search off this view, so it never has text to apply. // a search off this view, so it never has text to apply.
Destination.Reminders -> core.reminderNotes() Destination.Reminders -> core.reminderNotes()
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id, facets = searchOnly)) is Destination.WithLabel -> core.listNotes(query(VIEW_ALL, labelId = destination.id, facets = searchOnly))
} }
} }
@@ -647,6 +658,9 @@ class BoardViewModel(
private const val VIEW_ARCHIVE = "archived" private const val VIEW_ARCHIVE = "archived"
private const val VIEW_TRASH = "trash" private const val VIEW_TRASH = "trash"
/** The board and the Archive together — everything not in Trash. */
private const val VIEW_ALL = "all"
fun factory( fun factory(
core: Inkwell, core: Inkwell,
readFile: (Uri) -> PickedFile, readFile: (Uri) -> PickedFile,
@@ -55,6 +55,8 @@ fun NoteCard(
onConfirmDelete: () -> Unit, onConfirmDelete: () -> Unit,
/** The board is carrying this card (BoardDrag.kt). */ /** The board is carrying this card (BoardDrag.kt). */
dragging: Boolean = false, dragging: Boolean = false,
/** Whether an archived note says so — everywhere but the Archive itself. */
marksArchived: Boolean = true,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
@@ -107,7 +109,11 @@ fun NoteCard(
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS)) .border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp), .padding(12.dp),
) { ) {
CardContents(note = note, onToggleItem = onToggleItem) CardContents(
note = note,
onToggleItem = onToggleItem,
archivedChip = marksArchived && note.archived && !note.trashed,
)
} }
NoteMenu( NoteMenu(
@@ -120,12 +126,23 @@ fun NoteCard(
} }
} }
/** What a card shows, top to bottom: tags, body, links, attachments, reminder, sharing. */ /**
* What a card shows, top to bottom: archived or not, tags, body, links, attachments,
* reminder, sharing.
*/
@Composable @Composable
private fun CardContents( private fun CardContents(
note: Note, note: Note,
onToggleItem: (Int, Boolean) -> Unit, onToggleItem: (Int, Boolean) -> Unit,
archivedChip: Boolean,
) { ) {
// Above everything, tags included: whether a note is put away is the first thing
// to know about a search result or a tag's note, before what it says.
if (archivedChip) {
TintChip(stringResource(R.string.card_archived), "default")
Spacer(Modifier.height(8.dp))
}
// TAGS FIRST, on a row of their own above the body: a board is scanned, and // TAGS FIRST, on a row of their own above the body: a board is scanned, and
// what a note is about should be the first thing the eye lands on. Not beside // what a note is about should be the first thing the eye lands on. Not beside
// the body, whose first line is the note's name. // the body, whose first line is the note's name.
@@ -27,6 +27,7 @@
<!-- Board --> <!-- Board -->
<string name="board_empty_note">Empty note</string> <string name="board_empty_note">Empty note</string>
<string name="card_archived">Archived</string>
<!-- The long-press menu. Its ITEMS are the editor_* strings, deliberately: a <!-- The long-press menu. Its ITEMS are the editor_* strings, deliberately: a
note has one vocabulary of things you can do to it, and a board that said note has one vocabulary of things you can do to it, and a board that said
+8
View File
@@ -355,6 +355,9 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
// without turning up in their Trash, where they could do nothing with it. // without turning up in their Trash, where they could do nothing with it.
"trash" => "trashed = 1 AND permission = 'owner'", "trash" => "trashed = 1 AND permission = 'owner'",
"archived" => "trashed = 0 AND archived = 1", "archived" => "trashed = 0 AND archived = 1",
// The board and the Archive together: what a search or a tag looks through,
// so finished work stays findable after it has been put away.
"all" => "trashed = 0",
_ => "trashed = 0 AND archived = 0", _ => "trashed = 0 AND archived = 0",
}); });
@@ -1495,6 +1498,11 @@ mod tests {
ids(&list(&conn, json!({"view": "trash"}))), ids(&list(&conn, json!({"view": "trash"}))),
vec![trashed.id.as_str()] vec![trashed.id.as_str()]
); );
let mut all = ids(&list(&conn, json!({"view": "all"})));
all.sort();
let mut expected = vec![kept.id.as_str(), archived.id.as_str()];
expected.sort();
assert_eq!(all, expected);
} }
#[test] #[test]
+12
View File
@@ -52,6 +52,10 @@ const sharedLine = computed(() => {
return props.note.shared ? "Shared" : ""; return props.note.shared ? "Shared" : "";
}); });
// A search or a tag reaches into the Archive, so an archived card met anywhere but
// the Archive itself says so: put away, not current.
const showArchived = computed(() => props.note.archived && !props.note.trashed && notes.view !== "archived");
// --- Retention countdown, so someone browsing Trash can restore a note in time. // --- Retention countdown, so someone browsing Trash can restore a note in time.
// Null when not trashed or retention is off. --- // Null when not trashed or retention is off. ---
const trashDays = computed(() => const trashDays = computed(() =>
@@ -217,6 +221,14 @@ const tagColors = computed<Record<string, string>>(() => {
<!-- Tags first: a board is scanned, and tags say what a note is about. Above the <!-- Tags first: a board is scanned, and tags say what a note is about. Above the
body rather than beside it, so they don't compete with its first line, the body rather than beside it, so they don't compete with its first line, the
note's name. --> note's name. -->
<div v-if="showArchived" class="mb-2">
<span
class="inline-flex items-center gap-1 rounded-full bg-black/5 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:bg-white/10 dark:text-neutral-300"
>
<Icon name="archive" class="!h-3 !w-3" />
Archived
</span>
</div>
<div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1"> <div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1">
<!-- Every chip carries the `#`, so a lifted chip reads as the `#todo` somebody <!-- Every chip carries the `#`, so a lifted chip reads as the `#todo` somebody
typed, as on Android. --> typed, as on Android. -->
+10 -2
View File
@@ -3,7 +3,9 @@ import { ref } from "vue";
import { repo } from "../adapters"; import { repo } from "../adapters";
import { useUiStore } from "./ui"; import { useUiStore } from "./ui";
export type NoteView = "active" | "archived" | "trash"; // `all` is the board and the Archive together: what a search or a tag looks through,
// so finished work stays findable after it has been put away. Never Trash.
export type NoteView = "active" | "archived" | "trash" | "all";
// Combinable facet filters for the board (mirrors the GET /api/notes query). All // Combinable facet filters for the board (mirrors the GET /api/notes query). All
// optional; empty = the plain, unfiltered board. The date bounds are Timeline's. // optional; empty = the plain, unfiltered board. The date bounds are Timeline's.
export interface NoteFacets { export interface NoteFacets {
@@ -118,7 +120,13 @@ export const useNotesStore = defineStore("notes", () => {
function belongsHere(n: Note): boolean { function belongsHere(n: Note): boolean {
const v = view.value; const v = view.value;
const inView = const inView =
v === "trash" ? n.trashed : v === "archived" ? !n.trashed && n.archived : !n.trashed && !n.archived; v === "trash"
? n.trashed
: v === "archived"
? !n.trashed && n.archived
: v === "all"
? !n.trashed
: !n.trashed && !n.archived;
if (!inView) return false; if (!inView) return false;
if (activeFacets.value.shared === "with_me" && (n.permission ?? "owner") === "owner") return false; if (activeFacets.value.shared === "with_me" && (n.permission ?? "owner") === "owner") return false;
if (activeLabel.value) return n.labels.some((lb) => lb.id === activeLabel.value); if (activeLabel.value) return n.labels.some((lb) => lb.id === activeLabel.value);
+14 -9
View File
@@ -53,20 +53,25 @@ async function openFromQuery(id: string) {
void router.replace({ query: q }); void router.replace({ query: q });
} }
function viewForRoute(name: unknown): NoteView {
if (name === "archive") return "archived";
if (name === "trash") return "trash";
return "active"; // board + label views
}
const currentView = computed<NoteView>(() => viewForRoute(route.name));
const currentLabel = computed<string | null>(() => (route.name === "label" ? String(route.params.id) : null));
// Facet filters live in the URL query on the board route (a filtered board = a lens). // Facet filters live in the URL query on the board route (a filtered board = a lens).
const facets = computed(() => facetsFromQuery(route.query)); const facets = computed(() => facetsFromQuery(route.query));
const facetKey = computed(() => JSON.stringify(facetsToQuery(facets.value))); // stable; ignores ?open= const facetKey = computed(() => JSON.stringify(facetsToQuery(facets.value))); // stable; ignores ?open=
const filtered = computed(() => route.name === "board" && facetCount(facets.value) > 0); const filtered = computed(() => route.name === "board" && facetCount(facets.value) > 0);
// A search or a tag looks through the Archive as well as the board, so finished work
// can still be looked up; each archived card says so. Archive and Trash keep a search
// inside themselves.
function viewForRoute(name: unknown): NoteView {
if (name === "archive") return "archived";
if (name === "trash") return "trash";
if (name === "label") return "all";
const f = facets.value;
return name === "board" && (f.q || f.label?.length) ? "all" : "active";
}
const currentView = computed<NoteView>(() => viewForRoute(route.name));
const currentLabel = computed<string | null>(() => (route.name === "label" ? String(route.params.id) : null));
// Quick-add + pinned/others split only on the main board (not archive/trash/label). // Quick-add + pinned/others split only on the main board (not archive/trash/label).
const isMainBoard = computed(() => route.name === "board"); const isMainBoard = computed(() => route.name === "board");
const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned)); const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned));
+8 -3
View File
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
ALLOWED_IMAGE_MIMES = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"} ALLOWED_IMAGE_MIMES = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"}
VALID_FILTERS = {"active", "archived", "trash"} VALID_FILTERS = {"active", "archived", "trash", "all"}
DISPLAY_TITLE_CAP = 200 DISPLAY_TITLE_CAP = 200
@@ -72,9 +72,14 @@ def apply_filter(stmt, filter_name: str, archived):
Every branch excludes purge tombstones — content-less rows kept only so the sync Every branch excludes purge tombstones — content-less rows kept only so the sync
feed can tell offline clients a note is gone (see `retention.purge_note`). The feed can tell offline clients a note is gone (see `retention.purge_note`). The
active/archived branches get that for free from `deleted_at IS NULL`, since a active/archived/all branches get that for free from `deleted_at IS NULL`, since
tombstone keeps the timestamp; Trash is the one view that has to say so. a tombstone keeps the timestamp; Trash is the one view that has to say so.
`all` is the board and the Archive together: what a search or a tag looks
through, so finished work stays findable after it has been put away.
""" """
if filter_name == "all":
return stmt.where(Note.deleted_at.is_(None))
if filter_name == "archived": if filter_name == "archived":
return stmt.where(Note.deleted_at.is_(None), archived.is_(True)) return stmt.where(Note.deleted_at.is_(None), archived.is_(True))
if filter_name == "trash": if filter_name == "trash":
+15
View File
@@ -1673,6 +1673,21 @@ async def test_owner_and_recipient_each_pin_archive_and_order_their_own(app_clie
assert await _board_ids(app_client) == [nid, older] assert await _board_ids(app_client) == [nid, older]
async def test_search_and_tags_look_through_the_archive_but_not_the_trash(app_client, db):
kept = await _owners_note(app_client, "ferry times #travel")
archived = await _owners_note(app_client, "ferry booked last summer #travel")
trashed = await _owners_note(app_client, "ferry refund #travel")
await app_client.patch(f"/api/notes/{archived}", json={"archived": True})
await app_client.post(f"/api/notes/{trashed}/trash")
labels = (await (await app_client.get("/api/labels")).get_json())["labels"]
travel = next(lb["id"] for lb in labels if lb["name"] == "travel")
# The board alone still keeps the archive out of the way.
assert await _board_ids(app_client, "q=ferry") == [kept]
assert sorted(await _board_ids(app_client, "filter=all&q=ferry")) == sorted([kept, archived])
assert sorted(await _board_ids(app_client, f"filter=all&label={travel}")) == sorted([kept, archived])
async def test_unsharing_trashing_and_deleting_each_end_access(app_client, db): async def test_unsharing_trashing_and_deleting_each_end_access(app_client, db):
recipient, _, people = await _three_people(app_client) recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client) nid = await _owners_note(app_client)