all: remove saved views, the Has-reminder filter and the Created range
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 1m29s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m49s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 9m29s

Step 18 of the audit follow-through (#5180), on the operator's decisions.
Inkwell is for capture and recall (note 2897), and these three duplicated a
surface that does the job already:

- Saved views. They lived only on the web; the desktop kept its own set that
  never synced, and Android had none. Tags in the drawer already give
  one-click recall. Gone from the server (routes, model, migration 0038 drops
  the table), the core (store functions, schema v13 drops its table), the
  desktop commands, the web adapters, the drawer's Views list and the
  "Save view" link.
- The "Has reminder" facet. The Reminders page lists them, sorted by due.
- The FilterBar's "Created" range. Timeline is the date lens and keeps the
  created_after/created_before query it builds from local days, which also
  retires the UTC/local-day disagreement between the two (B3).

An old link that still carries the removed keys opens the plain board; a
web test pins that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 21:29:28 -04:00
co-authored by Claude Opus 5.5
parent 888c6410f0
commit b019172d47
25 changed files with 106 additions and 574 deletions
@@ -0,0 +1,41 @@
"""saved_filters: drop the table, saved views are gone
Revision ID: 0038
Revises: 0037
Create Date: 2026-10-08
Saved views were removed in #5180 (note 2897: recall first, organizing second). They
lived only on the web; the desktop kept its own set that never synced, and Android
had none. Tags in the drawer already give one-click recall.
## Downgrade
Recreates the table empty, as 0021 made it. The views that were stored are not
recoverable.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0038"
down_revision = "0037"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_index("ix_saved_filters_owner", table_name="saved_filters")
op.drop_table("saved_filters")
def downgrade() -> None:
op.create_table(
"saved_filters",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("params", sa.Text(), nullable=False, server_default="{}"),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_saved_filters_owner", "saved_filters", ["owner_id", "position"])
-3
View File
@@ -369,7 +369,6 @@ pub struct NoteQuery {
pub struct NoteFacets { pub struct NoteFacets {
pub q: Option<String>, pub q: Option<String>,
pub label: Option<Vec<String>>, pub label: Option<Vec<String>>,
pub has_reminder: Option<bool>,
pub has_attachment: Option<bool>, pub has_attachment: Option<bool>,
pub created_after: Option<String>, pub created_after: Option<String>,
pub created_before: Option<String>, pub created_before: Option<String>,
@@ -399,7 +398,6 @@ impl From<NoteFacets> for core_models::Facets {
let NoteFacets { let NoteFacets {
q, q,
label, label,
has_reminder,
has_attachment, has_attachment,
created_after, created_after,
created_before, created_before,
@@ -408,7 +406,6 @@ impl From<NoteFacets> for core_models::Facets {
core_models::Facets { core_models::Facets {
q, q,
label, label,
has_reminder,
has_attachment, has_attachment,
created_after, created_after,
created_before, created_before,
-11
View File
@@ -122,15 +122,6 @@ pub struct DueReminder {
pub due_ms: i64, pub due_ms: i64,
} }
#[derive(Serialize)]
pub struct SavedFilter {
pub id: String,
pub name: String,
/// Mirrors NoteFacets — stored as a JSON blob, round-tripped opaquely.
pub params: serde_json::Value,
pub position: i64,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct PublicConfig { pub struct PublicConfig {
pub site_name: String, pub site_name: String,
@@ -179,8 +170,6 @@ pub struct Facets {
#[serde(default)] #[serde(default)]
pub label: Option<Vec<String>>, pub label: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
pub has_reminder: Option<bool>,
#[serde(default)]
pub has_attachment: Option<bool>, pub has_attachment: Option<bool>,
#[serde(default)] #[serde(default)]
pub created_after: Option<String>, pub created_after: Option<String>,
+18 -37
View File
@@ -325,6 +325,12 @@ const SCHEMA_V12: &str = r#"
ALTER TABLE notes ADD COLUMN state_at TEXT; ALTER TABLE notes ADD COLUMN state_at TEXT;
"#; "#;
// v13 (#5180): saved views are gone. They never synced, so this device's set was its
// own and nothing else holds a copy.
const SCHEMA_V13: &str = r#"
DROP TABLE saved_filters;
"#;
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?; conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
@@ -376,6 +382,10 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA_V12)?; conn.execute_batch(SCHEMA_V12)?;
conn.execute_batch("PRAGMA user_version = 12;")?; conn.execute_batch("PRAGMA user_version = 12;")?;
} }
if version < 13 {
conn.execute_batch(SCHEMA_V13)?;
conn.execute_batch("PRAGMA user_version = 13;")?;
}
Ok(()) Ok(())
} }
@@ -490,7 +500,7 @@ mod tests {
let version: i64 = conn let version: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0)) .query_row("PRAGMA user_version", [], |r| r.get(0))
.expect("version"); .expect("version");
assert_eq!(version, 12); assert_eq!(version, 13);
} }
/// Every attachment that predates v10 came down the feed, so it is already on the /// Every attachment that predates v10 came down the feed, so it is already on the
@@ -544,46 +554,17 @@ mod tests {
assert!(label_columns.iter().any(|c| c == "color")); assert!(label_columns.iter().any(|c| c == "color"));
} }
/// A stored view that filtered on colour loses that criterion and keeps the rest.
/// The alternative — leaving the key — is a lens that silently narrows on a field
/// the app no longer has and never says why it returned nothing.
#[test] #[test]
fn v9_sweeps_colour_out_of_saved_filters() { fn v13_drops_saved_views() {
let conn = Connection::open_in_memory().expect("open"); let conn = Connection::open_in_memory().expect("open");
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
for batch in [
SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7,
] {
conn.execute_batch(batch).expect("schema");
}
conn.execute_batch("PRAGMA user_version = 8;").expect("v8");
for (id, params) in [
("a", r#"{"color":"teal","q":"milk"}"#),
("b", r#"{"q":"eggs"}"#),
// Not JSON at all. It must come out UNCHANGED rather than NULL — a blob
// this migration cannot read is not a blob it gets to destroy.
("c", "not json"),
] {
conn.execute(
"INSERT INTO saved_filters (id, name, params, created_at)
VALUES (?1, ?1, ?2, '2026-08-28T00:00:00.000Z')",
params![id, params],
)
.expect("seed");
}
migrate(&conn).expect("migrate"); migrate(&conn).expect("migrate");
let tables: i64 = conn
let read = |id: &str| -> String { .query_row(
conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE name = 'saved_filters'",
"SELECT params FROM saved_filters WHERE id = ?1", [],
[id],
|r| r.get(0), |r| r.get(0),
) )
.expect("read") .expect("count");
}; assert_eq!(tables, 0);
assert_eq!(read("a"), r#"{"q":"milk"}"#);
assert_eq!(read("b"), r#"{"q":"eggs"}"#);
assert_eq!(read("c"), "not json");
} }
} }
+1 -85
View File
@@ -414,9 +414,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
sql.push_str(" AND body LIKE ? ESCAPE '\\'"); sql.push_str(" AND body LIKE ? ESCAPE '\\'");
binds.push(format!("%{}%", escape_like(text))); binds.push(format!("%{}%", escape_like(text)));
} }
if f.has_reminder == Some(true) {
sql.push_str(" AND remind_at IS NOT NULL");
}
if f.has_attachment == Some(true) { if f.has_attachment == Some(true) {
sql.push_str(" AND EXISTS (SELECT 1 FROM attachments a WHERE a.note_id = notes.id)"); sql.push_str(" AND EXISTS (SELECT 1 FROM attachments a WHERE a.note_id = notes.id)");
} }
@@ -1152,75 +1149,6 @@ pub fn merge_labels(
load_label(conn, target_id) load_label(conn, target_id)
} }
// ---- saved filters ----------------------------------------------------------
const SAVED_FILTER_SELECT: &str = "SELECT id, name, params, position FROM saved_filters";
/// A stored filter. Params that no longer parse read as no filter at all rather
/// than failing the whole list.
fn saved_filter_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<SavedFilter> {
let params_str: String = r.get(2)?;
Ok(SavedFilter {
id: r.get(0)?,
name: r.get(1)?,
params: serde_json::from_str(&params_str).unwrap_or_else(|_| json!({})),
position: r.get(3)?,
})
}
pub fn list_saved_filters(conn: &Connection) -> rusqlite::Result<Vec<SavedFilter>> {
let mut stmt = conn.prepare(&format!(
"{SAVED_FILTER_SELECT} ORDER BY position ASC, name COLLATE NOCASE"
))?;
let rows = stmt.query_map([], saved_filter_row)?;
rows.collect()
}
pub fn create_saved_filter(
conn: &Connection,
name: &str,
params: &Value,
) -> rusqlite::Result<SavedFilter> {
let id = new_id();
let position: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), 0) + 1 FROM saved_filters",
[],
|r| r.get(0),
)?;
let params_str = serde_json::to_string(params).unwrap_or_else(|_| "{}".to_string());
conn.execute(
"INSERT INTO saved_filters (id, name, params, position, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, name, params_str, position, now()],
)?;
Ok(SavedFilter {
id,
name: name.to_string(),
params: params.clone(),
position,
})
}
pub fn remove_saved_filter(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM saved_filters WHERE id = ?1", [id])?;
Ok(())
}
pub fn rename_saved_filter(
conn: &Connection,
id: &str,
name: &str,
) -> rusqlite::Result<SavedFilter> {
conn.execute(
"UPDATE saved_filters SET name = ?1 WHERE id = ?2",
params![name, id],
)?;
conn.query_row(
&format!("{SAVED_FILTER_SELECT} WHERE id = ?1"),
[id],
saved_filter_row,
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
//! The store had no tests of its own until two bugs shipped in it unnoticed: //! The store had no tests of its own until two bugs shipped in it unnoticed:
@@ -1557,28 +1485,16 @@ mod tests {
} }
#[test] #[test]
fn reminder_and_attachment_facets() { fn attachment_facet() {
let conn = db(); let conn = db();
let reminded = note(&conn, "reminded");
let attached = note(&conn, "attached"); let attached = note(&conn, "attached");
note(&conn, "plain"); note(&conn, "plain");
update_note(
&conn,
&reminded.id,
&json!({"remind_at": "2026-12-01T09:00:00.000Z"}),
)
.expect("remind");
conn.execute( conn.execute(
"INSERT INTO attachments (id, note_id, url) VALUES ('a', ?1, '/x')", "INSERT INTO attachments (id, note_id, url) VALUES ('a', ?1, '/x')",
[&attached.id], [&attached.id],
) )
.expect("attach"); .expect("attach");
let r = list(
&conn,
json!({"view": "notes", "facets": {"has_reminder": true}}),
);
assert_eq!(ids(&r), vec![reminded.id.as_str()]);
let a = list( let a = list(
&conn, &conn,
json!({"view": "notes", "facets": {"has_attachment": true}}), json!({"view": "notes", "facets": {"has_attachment": true}}),
-32
View File
@@ -299,35 +299,3 @@ pub fn labels_merge(source_id: String, into: String, db: State<'_, Db>) -> Resul
let conn = db.conn()?; let conn = db.conn()?;
store::merge_labels(&conn, &source_id, &into).map_err(|e| e.to_string()) store::merge_labels(&conn, &source_id, &into).map_err(|e| e.to_string())
} }
#[tauri::command]
pub fn saved_filters_list(db: State<'_, Db>) -> Result<Vec<SavedFilter>, String> {
let conn = db.conn()?;
store::list_saved_filters(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn saved_filters_create(
name: String,
params: Value,
db: State<'_, Db>,
) -> Result<SavedFilter, String> {
let conn = db.conn()?;
store::create_saved_filter(&conn, &name, &params).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn saved_filters_remove(id: String, db: State<'_, Db>) -> Result<(), String> {
let conn = db.conn()?;
store::remove_saved_filter(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn saved_filters_rename(
id: String,
name: String,
db: State<'_, Db>,
) -> Result<SavedFilter, String> {
let conn = db.conn()?;
store::rename_saved_filter(&conn, &id, &name).map_err(|e| e.to_string())
}
-4
View File
@@ -200,10 +200,6 @@ pub fn run() {
commands::local::labels_set_color, commands::local::labels_set_color,
commands::local::labels_remove, commands::local::labels_remove,
commands::local::labels_merge, commands::local::labels_merge,
commands::local::saved_filters_list,
commands::local::saved_filters_create,
commands::local::saved_filters_remove,
commands::local::saved_filters_rename,
commands::sync::sync_probe, commands::sync::sync_probe,
commands::sync::sync_link, commands::sync::sync_link,
commands::sync::sync_unlink, commands::sync::sync_unlink,
-8
View File
@@ -11,7 +11,6 @@
import { invoke } from "../desktop/bridge"; import { invoke } from "../desktop/bridge";
import type { Note, NoteRevision } from "../stores/notes"; import type { Note, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels"; import type { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters";
import type { Device } from "../stores/devices"; import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles"; import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session"; import type { User } from "../stores/session";
@@ -82,13 +81,6 @@ export const local: Repo = {
titles: () => invoke<TitleEntry[]>("notes_titles"), titles: () => invoke<TitleEntry[]>("notes_titles"),
}, },
savedFilters: {
list: () => invoke<SavedFilter[]>("saved_filters_list"),
create: (name, params) => invoke<SavedFilter>("saved_filters_create", { name, params }),
remove: (id) => invoke<void>("saved_filters_remove", { id }),
rename: (id, name) => invoke<SavedFilter>("saved_filters_rename", { id, name }),
},
// Sharing is between accounts on a server, so these go to the linked one (#5175). // Sharing is between accounts on a server, so these go to the linked one (#5175).
// Unlinked, each rejects with the core's explanation, which the dialog shows. // Unlinked, each rejects with the core's explanation, which the dialog shows.
settings: { settings: {
-9
View File
@@ -11,7 +11,6 @@
import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes"; import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels"; import type { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters";
import type { Device } from "../stores/devices"; import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles"; import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session"; import type { User } from "../stores/session";
@@ -190,20 +189,12 @@ export interface SettingsRepo {
sendTestEmail(): Promise<{ to: string }>; sendTestEmail(): Promise<{ to: string }>;
} }
export interface SavedFiltersRepo {
list(): Promise<SavedFilter[]>;
create(name: string, params: NoteFacets): Promise<SavedFilter>;
remove(id: string): Promise<void>;
rename(id: string, name: string): Promise<SavedFilter>;
}
export interface Repo { export interface Repo {
config: ConfigRepo; config: ConfigRepo;
auth: AuthRepo; auth: AuthRepo;
devices: DevicesRepo; devices: DevicesRepo;
labels: LabelsRepo; labels: LabelsRepo;
notes: NotesRepo; notes: NotesRepo;
savedFilters: SavedFiltersRepo;
settings: SettingsRepo; settings: SettingsRepo;
shares: SharesRepo; shares: SharesRepo;
} }
-9
View File
@@ -7,7 +7,6 @@
import { api } from "../api/client"; import { api } from "../api/client";
import type { Note, NoteRevision } from "../stores/notes"; import type { Note, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels"; import type { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters";
import type { Device } from "../stores/devices"; import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles"; import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session"; import type { User } from "../stores/session";
@@ -34,7 +33,6 @@ function notesQuery(q: NoteListQuery): string {
if (q.labelId) params.append("label", q.labelId); if (q.labelId) params.append("label", q.labelId);
for (const id of q.facets?.label ?? []) if (id) params.append("label", id); for (const id of q.facets?.label ?? []) if (id) params.append("label", id);
if (q.facets?.q) params.set("q", q.facets.q); if (q.facets?.q) params.set("q", q.facets.q);
if (q.facets?.has_reminder) params.set("has_reminder", "true");
if (q.facets?.has_attachment) params.set("has_attachment", "true"); if (q.facets?.has_attachment) params.set("has_attachment", "true");
if (q.facets?.created_after) params.set("created_after", q.facets.created_after); if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
if (q.facets?.created_before) params.set("created_before", q.facets.created_before); if (q.facets?.created_before) params.set("created_before", q.facets.created_before);
@@ -110,13 +108,6 @@ export const rest: Repo = {
titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles, titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles,
}, },
savedFilters: {
list: async () => (await api.get<{ filters: SavedFilter[] }>("/api/saved-filters")).filters,
create: (name, params) => api.post<SavedFilter>("/api/saved-filters", { name, params }),
remove: (id) => api.del<void>(`/api/saved-filters/${id}`),
rename: (id, name) => api.patch<SavedFilter>(`/api/saved-filters/${id}`, { name }),
},
settings: { settings: {
list: async () => (await api.get<{ settings: ServerSetting[] }>("/api/settings")).settings, list: async () => (await api.get<{ settings: ServerSetting[] }>("/api/settings")).settings,
save: async (values) => save: async (values) =>
+1 -37
View File
@@ -4,7 +4,6 @@ import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
import { useSessionStore } from "../stores/session"; import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config"; import { useConfigStore } from "../stores/config";
import { useLabelsStore } from "../stores/labels"; import { useLabelsStore } from "../stores/labels";
import { useSavedFiltersStore, type SavedFilter } from "../stores/savedFilters";
import { useReminderStore } from "../stores/reminders"; import { useReminderStore } from "../stores/reminders";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import BaseModal from "./BaseModal.vue"; import BaseModal from "./BaseModal.vue";
@@ -16,7 +15,6 @@ import LabelsModal from "./LabelsModal.vue";
import ShareDialog from "./ShareDialog.vue"; import ShareDialog from "./ShareDialog.vue";
import { isDesktop } from "../desktop/bridge"; import { isDesktop } from "../desktop/bridge";
import { useLensName } from "../composables/useLensName"; import { useLensName } from "../composables/useLensName";
import { facetsToQuery } from "../notes/facets";
import { labelDotClasses } from "../notes/colors"; import { labelDotClasses } from "../notes/colors";
const route = useRoute(); const route = useRoute();
@@ -24,7 +22,6 @@ const router = useRouter();
const session = useSessionStore(); const session = useSessionStore();
const config = useConfigStore(); const config = useConfigStore();
const labels = useLabelsStore(); const labels = useLabelsStore();
const savedFilters = useSavedFiltersStore();
const reminders = useReminderStore(); const reminders = useReminderStore();
const ui = useUiStore(); const ui = useUiStore();
// Sync is a desktop-app concern: the web build already IS the server's UI. // Sync is a desktop-app concern: the web build already IS the server's UI.
@@ -47,15 +44,6 @@ const buildLabel = computed(
() => `Inkwell ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`, () => `Inkwell ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`,
); );
async function removeView(f: SavedFilter) {
if (!window.confirm(`Delete the "${f.name}" view?`)) return;
try {
await savedFilters.remove(f.id);
} catch {
ui.showToast("Couldn't delete that view.");
}
}
const managing = ref(false); const managing = ref(false);
const showShortcuts = ref(false); const showShortcuts = ref(false);
const paletteOpen = ref(false); const paletteOpen = ref(false);
@@ -182,7 +170,6 @@ function onKeydown(e: KeyboardEvent) {
onMounted(() => { onMounted(() => {
if (!labels.loaded) void labels.load(); if (!labels.loaded) void labels.load();
if (!savedFilters.loaded) void savedFilters.load();
reminders.start(); // foreground reminder delivery while the app is open reminders.start(); // foreground reminder delivery while the app is open
window.addEventListener("keydown", onKeydown); window.addEventListener("keydown", onKeydown);
}); });
@@ -222,7 +209,7 @@ function onSearch(value: string) {
// The URL is the filter state (see notes/facets.ts), so the box READS from it rather // The URL is the filter state (see notes/facets.ts), so the box READS from it rather
// than holding its own copy — which is also what keeps it in step with the Filters // than holding its own copy — which is also what keeps it in step with the Filters
// panel's Clear button and with a saved view opened from the sidebar. // panel's Clear button.
watch( watch(
() => route.query.q, () => route.query.q,
(q) => { (q) => {
@@ -435,29 +422,6 @@ async function signOut() {
<span class="truncate">{{ lb.name }}</span> <span class="truncate">{{ lb.name }}</span>
</RouterLink> </RouterLink>
<template v-if="savedFilters.items.length">
<div class="mt-3 px-3 pb-1">
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Views</span>
</div>
<RouterLink
v-for="f in savedFilters.items"
:key="f.id"
:to="{ path: '/', query: facetsToQuery(f.params) }"
class="nav-link group/view"
>
<Icon name="filter" />
<span class="flex-1 truncate">{{ f.name }}</span>
<button
type="button"
class="hover-reveal text-neutral-400 opacity-0 hover:text-red-500 group-hover/view:opacity-100"
:aria-label="`Delete view ${f.name}`"
@click.prevent.stop="removeView(f)"
>
×
</button>
</RouterLink>
</template>
<RouterLink to="/archive" class="nav-link mt-3" exact-active-class="nav-link-active"> <RouterLink to="/archive" class="nav-link mt-3" exact-active-class="nav-link-active">
<Icon name="archive" /> Archive <Icon name="archive" /> Archive
</RouterLink> </RouterLink>
+9 -70
View File
@@ -2,22 +2,16 @@
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useLabelsStore } from "../stores/labels"; import { useLabelsStore } from "../stores/labels";
import { useSavedFiltersStore } from "../stores/savedFilters";
import { useUiStore } from "../stores/ui";
import type { NoteFacets } from "../stores/notes"; import type { NoteFacets } from "../stores/notes";
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets"; import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import { errorMessage } from "../api/errors";
// A dead-simple facet bar over the board: color + labels + has-reminder // A dead-simple facet bar over the board: tags, has-attachment and shared-with-me.
// + has-attachment + created-date range. The URL query IS the state, so a // The URL query IS the state, so a filtered board is a shareable link. Reminders
// filtered board is a shareable lens and a saved view is just a link. // and dates have their own lenses (Reminders, Timeline).
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const labels = useLabelsStore(); const labels = useLabelsStore();
const savedFilters = useSavedFiltersStore();
const ui = useUiStore();
const open = ref(false); const open = ref(false);
const facets = computed<NoteFacets>(() => facetsFromQuery(route.query)); const facets = computed<NoteFacets>(() => facetsFromQuery(route.query));
@@ -37,9 +31,6 @@ function toggleLabel(id: string) {
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]; const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
patch({ label: next.length ? next : undefined }); patch({ label: next.length ? next : undefined });
} }
function toggleReminder() {
patch({ has_reminder: facets.value.has_reminder ? undefined : true });
}
function toggleAttachment() { function toggleAttachment() {
patch({ has_attachment: facets.value.has_attachment ? undefined : true }); patch({ has_attachment: facets.value.has_attachment ? undefined : true });
} }
@@ -47,38 +38,6 @@ function toggleShared() {
patch({ shared: facets.value.shared ? undefined : "with_me" }); patch({ shared: facets.value.shared ? undefined : "with_me" });
} }
function onFrom(e: Event) {
const v = (e.target as HTMLInputElement).value;
patch({ created_after: v ? `${v}T00:00:00` : undefined });
}
function onTo(e: Event) {
const v = (e.target as HTMLInputElement).value;
const d = v ? parseLocalDate(v) : null;
if (!d) {
patch({ created_before: undefined });
return;
}
// Half-open upper bound: the start of the day AFTER the chosen date (so it's inclusive).
patch({ created_before: `${formatLocalDay(addLocalDays(d, 1))}T00:00:00` });
}
const fromInput = computed(() => (facets.value.created_after ?? "").slice(0, 10));
const toInput = computed(() => {
// Reverse the half-open bound (day-after) back to the chosen end day for the input.
const d = facets.value.created_before ? parseLocalDate(facets.value.created_before.slice(0, 10)) : null;
return d ? formatLocalDay(addLocalDays(d, -1)) : "";
});
async function saveView() {
const name = window.prompt("Name this view:");
if (!name?.trim()) return;
try {
await savedFilters.create(name.trim(), facets.value);
ui.showToast(`Saved view "${name.trim()}".`);
} catch (e) {
ui.showToast(errorMessage(e, "Couldn't save the view."));
}
}
const chipBase = const chipBase =
"rounded-full border px-2.5 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"; "rounded-full border px-2.5 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand";
const chipOn = "border-brand bg-brand/10 text-brand-700 dark:text-brand"; const chipOn = "border-brand bg-brand/10 text-brand-700 dark:text-brand";
@@ -97,14 +56,14 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
<Icon name="filter" /> Filters <Icon name="filter" /> Filters
<span v-if="count" class="rounded-full bg-brand px-1.5 text-xs font-semibold text-black">{{ count }}</span> <span v-if="count" class="rounded-full bg-brand px-1.5 text-xs font-semibold text-black">{{ count }}</span>
</button> </button>
<template v-if="count"> <button
<button type="button" class="text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300" @click="clearAll"> v-if="count"
type="button"
class="text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="clearAll"
>
Clear Clear
</button> </button>
<button type="button" class="text-xs font-medium text-brand-700 underline dark:text-brand" @click="saveView">
Save view
</button>
</template>
</div> </div>
<div <div
@@ -126,9 +85,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Only</span> <span class="w-16 shrink-0 text-xs text-neutral-400">Only</span>
<button type="button" :class="[chipBase, facets.has_reminder ? chipOn : chipOff]" @click="toggleReminder">
Has reminder
</button>
<button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment"> <button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment">
Has attachment Has attachment
</button> </button>
@@ -140,23 +96,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
Shared with me Shared with me
</button> </button>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<span class="w-16 shrink-0 text-xs text-neutral-400">Created</span>
<input
type="date"
:value="fromInput"
class="rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
@change="onFrom"
/>
<span class="text-xs text-neutral-400">to</span>
<input
type="date"
:value="toInput"
class="rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
@change="onTo"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { facetCount, facetsFromQuery, facetsToQuery } from "./facets";
describe("the board's filter URL", () => {
it("round-trips the facets the Filters panel offers", () => {
const query = { q: "milk", label: ["a", "b"], has_attachment: "true", shared: "with_me" };
const facets = facetsFromQuery(query);
expect(facetsToQuery(facets)).toEqual(query);
expect(facetCount(facets)).toBe(5);
});
// Removed in #5180: reminders have their own lens and dates are Timeline's. A link
// saved before then must open the plain board, not one filtered with no chip to say so.
it("ignores the reminder and date keys an old link may carry", () => {
const facets = facetsFromQuery({
has_reminder: "true",
created_after: "2026-01-01T00:00:00",
created_before: "2026-02-01T00:00:00",
});
expect(facets).toEqual({});
expect(facetCount(facets)).toBe(0);
});
});
+2 -11
View File
@@ -1,5 +1,6 @@
// Convert between the board's URL query and a NoteFacets object. The URL query IS the // Convert between the board's URL query and a NoteFacets object. The URL query IS the
// filter state (so a lens is shareable/bookmarkable and a saved view is just a link). // filter state, so a filtered board is shareable and bookmarkable. The date bounds are
// Timeline's, which builds its own query, so they never ride in the URL.
import type { LocationQuery, LocationQueryRaw } from "vue-router"; import type { LocationQuery, LocationQueryRaw } from "vue-router";
import type { NoteFacets } from "../stores/notes"; import type { NoteFacets } from "../stores/notes";
@@ -16,12 +17,7 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
const text = one(q.q); const text = one(q.q);
if (text) f.q = text; if (text) f.q = text;
if (labels.length) f.label = labels; if (labels.length) f.label = labels;
if (one(q.has_reminder) === "true") f.has_reminder = true;
if (one(q.has_attachment) === "true") f.has_attachment = true; if (one(q.has_attachment) === "true") f.has_attachment = true;
const after = one(q.created_after);
if (after) f.created_after = after;
const before = one(q.created_before);
if (before) f.created_before = before;
if (one(q.shared) === "with_me") f.shared = "with_me"; if (one(q.shared) === "with_me") f.shared = "with_me";
return f; return f;
} }
@@ -30,10 +26,7 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
const q: LocationQueryRaw = {}; const q: LocationQueryRaw = {};
if (f.q) q.q = f.q; if (f.q) q.q = f.q;
if (f.label?.length) q.label = f.label; if (f.label?.length) q.label = f.label;
if (f.has_reminder) q.has_reminder = "true";
if (f.has_attachment) q.has_attachment = "true"; if (f.has_attachment) q.has_attachment = "true";
if (f.created_after) q.created_after = f.created_after;
if (f.created_before) q.created_before = f.created_before;
if (f.shared) q.shared = f.shared; if (f.shared) q.shared = f.shared;
return q; return q;
} }
@@ -43,9 +36,7 @@ export function facetCount(f: NoteFacets): number {
let n = 0; let n = 0;
if (f.q) n++; if (f.q) n++;
n += f.label?.length ?? 0; n += f.label?.length ?? 0;
if (f.has_reminder) n++;
if (f.has_attachment) n++; if (f.has_attachment) n++;
if (f.created_after || f.created_before) n++;
if (f.shared) n++; if (f.shared) n++;
return n; return n;
} }
+2 -3
View File
@@ -4,12 +4,11 @@ import { repo } from "../adapters";
import { useUiStore } from "./ui"; import { useUiStore } from "./ui";
export type NoteView = "active" | "archived" | "trash"; export type NoteView = "active" | "archived" | "trash";
// Combinable facet filters for the board (mirrors the GET /api/notes query + a saved // Combinable facet filters for the board (mirrors the GET /api/notes query). All
// view's stored params). All optional; empty = the plain, unfiltered board. // optional; empty = the plain, unfiltered board. The date bounds are Timeline's.
export interface NoteFacets { export interface NoteFacets {
q?: string; q?: string;
label?: string[]; label?: string[];
has_reminder?: boolean;
has_attachment?: boolean; has_attachment?: boolean;
created_after?: string; created_after?: string;
created_before?: string; created_before?: string;
-46
View File
@@ -1,46 +0,0 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { repo } from "../adapters";
import type { NoteFacets } from "./notes";
// A named, saved facet combination (a 'view'/lens). `params` mirrors NoteFacets.
export interface SavedFilter {
id: string;
name: string;
params: NoteFacets;
position: number;
}
export const useSavedFiltersStore = defineStore("savedFilters", () => {
const items = ref<SavedFilter[]>([]);
const loaded = ref(false);
async function load(): Promise<void> {
try {
items.value = await repo.savedFilters.list();
} catch {
// leave whatever we have
} finally {
loaded.value = true;
}
}
async function create(name: string, params: NoteFacets): Promise<SavedFilter> {
const sf = await repo.savedFilters.create(name, params);
items.value.push(sf);
return sf;
}
async function remove(id: string): Promise<void> {
await repo.savedFilters.remove(id);
items.value = items.value.filter((f) => f.id !== id);
}
async function rename(id: string, name: string): Promise<void> {
const sf = await repo.savedFilters.rename(id, name);
const idx = items.value.findIndex((f) => f.id === id);
if (idx >= 0) items.value[idx] = sf;
}
return { items, loaded, load, create, remove, rename };
});
-2
View File
@@ -22,7 +22,6 @@ from .labels import bp as labels_bp
from .notes import bp as notes_bp from .notes import bp as notes_bp
from .proxy import is_https from .proxy import is_https
from .retention import run_sweeper from .retention import run_sweeper
from .saved_filters import bp as saved_filters_bp
from .settings import get_public_config, get_setting, load_or_create_secret_key, refresh_live from .settings import get_public_config, get_setting, load_or_create_secret_key, refresh_live
from .settings_api import bp as settings_bp from .settings_api import bp as settings_bp
from .shares_api import bp as shares_bp from .shares_api import bp as shares_bp
@@ -100,7 +99,6 @@ def create_app() -> Quart:
app.register_blueprint(groups_bp) app.register_blueprint(groups_bp)
app.register_blueprint(shares_bp) app.register_blueprint(shares_bp)
app.register_blueprint(sync_bp) app.register_blueprint(sync_bp)
app.register_blueprint(saved_filters_bp)
app.register_blueprint(client_bp) app.register_blueprint(client_bp)
@app.before_serving @app.before_serving
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
# It lived on `models/note.py` until M315, when a note stopped having a colour. A # It lived on `models/note.py` until M315, when a note stopped having a colour. A
# palette defined on the model that lost one would be a standing invitation to put the # palette defined on the model that lost one would be a standing invitation to put the
# column back; here it reads as what it now is — a LABEL's vocabulary, shared with the # column back; here it reads as what it now is — a LABEL's vocabulary, shared with the
# saved-filter and import paths that still name a colour. # import path that still names a colour.
# #
# Keys, not tints. The actual colours live in each client (frontend/src/notes/colors.ts # Keys, not tints. The actual colours live in each client (frontend/src/notes/colors.ts
# and NoteTint.kt), so they can be retuned without a schema migration — which M315 spent # and NoteTint.kt), so they can be retuned without a schema migration — which M315 spent
+1 -1
View File
@@ -31,7 +31,7 @@ def parse_dt(raw: object) -> datetime | None:
def coerce_bool(raw: object) -> bool: def coerce_bool(raw: object) -> bool:
"""Truthy for the common flag spellings ('true'/'1'/'yes'/'on', or a real bool). """Truthy for the common flag spellings ('true'/'1'/'yes'/'on', or a real bool).
Used for query-string booleans (?has_reminder=true) and DB-backed bool settings, Used for query-string booleans (?has_attachment=true) and DB-backed bool settings,
which arrive as strings; anything else (a number from a JSON body, None) is its which arrive as strings; anything else (a number from a JSON body, None) is its
ordinary truthiness. ordinary truthiness.
""" """
-1
View File
@@ -14,7 +14,6 @@ from . import ( # noqa: F401
note_revision, note_revision,
note_user_state, note_user_state,
password_reset, password_reset,
saved_filter,
settings, settings,
share, share,
share_revocation, share_revocation,
-28
View File
@@ -1,28 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class SavedFilter(Base):
"""A named, saved facet combination (a 'view'/lens) the user can re-apply in one
click — e.g. "#ideas with a reminder". `params` is a JSON-encoded facet dict
matching the GET /api/notes query (q/labels/has_reminder/has_attachment/date
range). Colour was a facet until M315; 0029 swept the key out of stored rows."""
__tablename__ = "saved_filters"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(Text(), nullable=False)
params: Mapped[str] = mapped_column(Text(), nullable=False, server_default="{}")
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+2 -5
View File
@@ -102,10 +102,9 @@ async def list_notes():
filter_name = request.args.get("filter", "active") filter_name = request.args.get("filter", "active")
if filter_name not in VALID_FILTERS: if filter_name not in VALID_FILTERS:
return json_error("invalid filter", 400) return json_error("invalid filter", 400)
# Combinable facet filters (all optional, AND-ed together) — the rich-search / # Combinable facet filters (all optional, AND-ed together) — the Filters panel.
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them. # Multiple ?label= narrow to notes carrying ALL of them.
label_params = request.args.getlist("label") label_params = request.args.getlist("label")
has_reminder = coerce_bool(request.args.get("has_reminder"))
has_attachment = coerce_bool(request.args.get("has_attachment")) has_attachment = coerce_bool(request.args.get("has_attachment"))
query_text = (request.args.get("q") or "").strip() query_text = (request.args.get("q") or "").strip()
# Optional creation-date range — the "browse by when" / Timeline lens. Both bounds # Optional creation-date range — the "browse by when" / Timeline lens. Both bounds
@@ -137,8 +136,6 @@ async def list_notes():
if lid is None: if lid is None:
return json_error("invalid label", 400) return json_error("invalid label", 400)
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid))) stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
if has_reminder:
stmt = stmt.where(Note.remind_at.is_not(None))
if has_attachment: if has_attachment:
stmt = stmt.where(Note.id.in_(select(NoteAttachment.note_id))) stmt = stmt.where(Note.id.in_(select(NoteAttachment.note_id)))
if after_param: if after_param:
-123
View File
@@ -1,123 +0,0 @@
"""Saved filters ('views'/lenses) — a named facet combination the user can re-apply
in one click. Owner-scoped CRUD; `params` mirrors the GET /api/notes facet query."""
from __future__ import annotations
import json
from quart import Blueprint, g, jsonify, request
from sqlalchemy import func, select
from .auth import login_required
from .db import session_scope
from .models.saved_filter import SavedFilter
from .responses import json_error, not_found, parse_uuid
bp = Blueprint("saved_filters", __name__, url_prefix="/api/saved-filters")
NAME_CAP = 100
# Facet keys allowed in a saved view (must match the GET /api/notes query surface).
# `color` was here until M315. A note has no colour to filter on, and `clean_params`
# drops the key on the way in — the migration that dropped the column sweeps it out of
# the views already stored.
_ALLOWED_PARAM_KEYS = {
"q",
"label", # matches the repeatable ?label= query param (stored as an array)
"has_reminder",
"has_attachment",
"created_after",
"created_before",
"shared", # "with_me": notes other people shared with you (#5174)
"filter",
}
def clean_params(raw: object) -> dict:
"""Keep only recognized facet keys, so a saved view can't accumulate junk."""
if not isinstance(raw, dict):
return {}
return {k: v for k, v in raw.items() if k in _ALLOWED_PARAM_KEYS}
def _serialize(sf: SavedFilter) -> dict:
try:
params = json.loads(sf.params)
except (ValueError, TypeError):
params = {}
return {"id": str(sf.id), "name": sf.name, "params": params, "position": sf.position}
@bp.get("")
@login_required
async def list_saved():
async with session_scope() as db:
rows = (
await db.scalars(
select(SavedFilter)
.where(SavedFilter.owner_id == g.user_id)
.order_by(SavedFilter.position, SavedFilter.created_at)
)
).all()
return jsonify({"filters": [_serialize(r) for r in rows]})
@bp.post("")
@login_required
async def create_saved():
data = await request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return json_error("name is required", 400)
params = clean_params(data.get("params"))
async with session_scope() as db:
max_pos = await db.scalar(
select(func.coalesce(func.max(SavedFilter.position), -1)).where(SavedFilter.owner_id == g.user_id)
)
sf = SavedFilter(
owner_id=g.user_id, name=name[:NAME_CAP], params=json.dumps(params), position=int(max_pos) + 1
)
db.add(sf)
await db.commit()
await db.refresh(sf)
return jsonify(_serialize(sf)), 201
@bp.patch("/<filter_id>")
@login_required
async def rename_saved(filter_id: str):
fid = parse_uuid(filter_id)
if fid is None:
return not_found()
data = await request.get_json(silent=True) or {}
async with session_scope() as db:
sf = await db.scalar(
select(SavedFilter).where(SavedFilter.id == fid, SavedFilter.owner_id == g.user_id)
)
if sf is None:
return not_found()
if "name" in data:
name = (data.get("name") or "").strip()
if not name:
return json_error("name is required", 400)
sf.name = name[:NAME_CAP]
if "params" in data:
sf.params = json.dumps(clean_params(data.get("params")))
await db.commit()
await db.refresh(sf)
return jsonify(_serialize(sf))
@bp.delete("/<filter_id>")
@login_required
async def delete_saved(filter_id: str):
fid = parse_uuid(filter_id)
if fid is None:
return not_found()
async with session_scope() as db:
sf = await db.scalar(
select(SavedFilter).where(SavedFilter.id == fid, SavedFilter.owner_id == g.user_id)
)
if sf is None:
return not_found()
await db.delete(sf)
await db.commit()
return jsonify({"ok": True})
+2 -2
View File
@@ -59,8 +59,8 @@ def test_all_note_routes_registered(app):
} }
assert expected <= registered, f"unregistered note routes: {expected - registered}" assert expected <= registered, f"unregistered note routes: {expected - registered}"
# Removed in #5178: an item is added, reworded and removed by editing the body, # Removed in #5178: an item is added, reworded and removed by editing the body,
# and previews arrive in the background after a save. # and previews arrive in the background after a save. Saved views went in #5180.
gone = {"notes.add_item", "notes.delete_item", "notes.unfurl_link"} gone = {"notes.add_item", "notes.delete_item", "notes.unfurl_link", "saved_filters.list_saved"}
assert not gone & registered, f"removed note routes are back: {gone & registered}" assert not gone & registered, f"removed note routes are back: {gone & registered}"
-44
View File
@@ -1,44 +0,0 @@
import pytest
from inkwell.app import create_app
from inkwell.saved_filters import clean_params
@pytest.fixture
def app():
return create_app()
def test_clean_params_whitelists_facet_keys():
raw = {
"q": "hi",
# A facet until M315. It is junk now, and has to be dropped like any other —
# a stored view that still filtered on a field the app lost would return
# nothing and never say why.
"color": "yellow",
"label": ["a"],
"has_reminder": True,
"junk": 1,
"__proto__": 2,
}
assert clean_params(raw) == {"q": "hi", "label": ["a"], "has_reminder": True}
assert clean_params("nope") == {}
assert clean_params(None) == {}
async def test_list_saved_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/saved-filters")
assert resp.status_code == 401
async def test_create_saved_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/saved-filters", json={"name": "x"})
assert resp.status_code == 401
async def test_delete_saved_requires_auth(app):
client = app.test_client()
resp = await client.delete("/api/saved-filters/00000000-0000-0000-0000-000000000000")
assert resp.status_code == 401