From b98a0c315f307e0e7ca2b84f53333a6b07fa7ebf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 23 Oct 2026 16:23:08 -0400 Subject: [PATCH] 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 --- .../com/fabledsword/inkwell/ui/BoardScreen.kt | 4 ++++ .../fabledsword/inkwell/ui/BoardViewModel.kt | 18 +++++++++++++-- .../com/fabledsword/inkwell/ui/NoteCard.kt | 21 +++++++++++++++-- android/app/src/main/res/values/strings.xml | 1 + core/src/local/store.rs | 8 +++++++ frontend/src/components/NoteCard.vue | 12 ++++++++++ frontend/src/stores/notes.ts | 12 ++++++++-- frontend/src/views/BoardView.vue | 23 +++++++++++-------- src/inkwell/notes/helpers.py | 11 ++++++--- tests/test_integration.py | 15 ++++++++++++ 10 files changed, 107 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardScreen.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardScreen.kt index 2bb5dea..5f59453 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardScreen.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardScreen.kt @@ -258,6 +258,7 @@ fun BoardScreen( onNoteAction = onCardAction, onConfirmDelete = { confirmingDelete = it }, reorderable = state.reorderable, + marksArchived = state.marksArchived, onReorder = onReorder, ) } @@ -540,6 +541,8 @@ private fun NoteBoard( onConfirmDelete: (Note) -> Unit, /** Whether cards can be dragged into a new order: the plain main board only. */ reorderable: Boolean, + /** Whether an archived card carries the Archived chip (BoardState.marksArchived). */ + marksArchived: Boolean, onReorder: (List) -> Unit, ) { // The web's breakpoints (NoteGrid.vue: three columns from `lg`, 1024px, four from @@ -635,6 +638,7 @@ private fun NoteBoard( onAction = { onNoteAction(note, it) }, onConfirmDelete = { onConfirmDelete(note) }, dragging = carried, + marksArchived = marksArchived, ) } DisposableEffect(note.id) { onDispose { bounds.forget(note.id) } } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt index d741076..dd29c98 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt @@ -91,6 +91,14 @@ data class BoardState( * filtered subset, whose order would be renumbered against notes it can't see. */ 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 { // Only the main board has filters; every board view takes the search 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) { - 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.Trash -> core.listNotes(query(VIEW_TRASH, facets = searchOnly)) // Not a board view: the core models reminders as its own query, since // "has a reminder" cuts across archived and active alike. `search` moves // a search off this view, so it never has text to apply. 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_TRASH = "trash" + /** The board and the Archive together — everything not in Trash. */ + private const val VIEW_ALL = "all" + fun factory( core: Inkwell, readFile: (Uri) -> PickedFile, diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt index 62b3a0c..836273a 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt @@ -55,6 +55,8 @@ fun NoteCard( onConfirmDelete: () -> Unit, /** The board is carrying this card (BoardDrag.kt). */ dragging: Boolean = false, + /** Whether an archived note says so — everywhere but the Archive itself. */ + marksArchived: Boolean = true, ) { val dark = isSystemInDarkTheme() 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)) .padding(12.dp), ) { - CardContents(note = note, onToggleItem = onToggleItem) + CardContents( + note = note, + onToggleItem = onToggleItem, + archivedChip = marksArchived && note.archived && !note.trashed, + ) } 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 private fun CardContents( note: Note, 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 // 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. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e5c7b22..c38b7b5 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -27,6 +27,7 @@ Empty note + Archived +
+ + + Archived + +
diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 3e2a16a..9cf1e6f 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -3,7 +3,9 @@ import { ref } from "vue"; import { repo } from "../adapters"; 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 // optional; empty = the plain, unfiltered board. The date bounds are Timeline's. export interface NoteFacets { @@ -118,7 +120,13 @@ export const useNotesStore = defineStore("notes", () => { function belongsHere(n: Note): boolean { const v = view.value; 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 (activeFacets.value.shared === "with_me" && (n.permission ?? "owner") === "owner") return false; if (activeLabel.value) return n.labels.some((lb) => lb.id === activeLabel.value); diff --git a/frontend/src/views/BoardView.vue b/frontend/src/views/BoardView.vue index e1701ad..34f5a0d 100644 --- a/frontend/src/views/BoardView.vue +++ b/frontend/src/views/BoardView.vue @@ -53,20 +53,25 @@ async function openFromQuery(id: string) { 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(() => viewForRoute(route.name)); -const currentLabel = computed(() => (route.name === "label" ? String(route.params.id) : null)); - // Facet filters live in the URL query on the board route (a filtered board = a lens). const facets = computed(() => facetsFromQuery(route.query)); const facetKey = computed(() => JSON.stringify(facetsToQuery(facets.value))); // stable; ignores ?open= 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(() => viewForRoute(route.name)); +const currentLabel = computed(() => (route.name === "label" ? String(route.params.id) : null)); + // Quick-add + pinned/others split only on the main board (not archive/trash/label). const isMainBoard = computed(() => route.name === "board"); const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned)); diff --git a/src/inkwell/notes/helpers.py b/src/inkwell/notes/helpers.py index e08c251..8fd3386 100644 --- a/src/inkwell/notes/helpers.py +++ b/src/inkwell/notes/helpers.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) 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 @@ -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 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 - tombstone keeps the timestamp; Trash is the one view that has to say so. + active/archived/all branches get that for free from `deleted_at IS NULL`, since + 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": return stmt.where(Note.deleted_at.is_(None), archived.is_(True)) if filter_name == "trash": diff --git a/tests/test_integration.py b/tests/test_integration.py index b007d56..687c4c1 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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] +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): recipient, _, people = await _three_people(app_client) nid = await _owners_note(app_client)