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:
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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. -->
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user