diff --git a/alembic/versions/0038_drop_saved_filters.py b/alembic/versions/0038_drop_saved_filters.py new file mode 100644 index 0000000..c595a28 --- /dev/null +++ b/alembic/versions/0038_drop_saved_filters.py @@ -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"]) diff --git a/android/ffi/src/models.rs b/android/ffi/src/models.rs index 8dd2dd2..d33aa9b 100644 --- a/android/ffi/src/models.rs +++ b/android/ffi/src/models.rs @@ -369,7 +369,6 @@ pub struct NoteQuery { pub struct NoteFacets { pub q: Option, pub label: Option>, - pub has_reminder: Option, pub has_attachment: Option, pub created_after: Option, pub created_before: Option, @@ -399,7 +398,6 @@ impl From for core_models::Facets { let NoteFacets { q, label, - has_reminder, has_attachment, created_after, created_before, @@ -408,7 +406,6 @@ impl From for core_models::Facets { core_models::Facets { q, label, - has_reminder, has_attachment, created_after, created_before, diff --git a/core/src/local/models.rs b/core/src/local/models.rs index ef2ddc5..2c38acc 100644 --- a/core/src/local/models.rs +++ b/core/src/local/models.rs @@ -122,15 +122,6 @@ pub struct DueReminder { 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)] pub struct PublicConfig { pub site_name: String, @@ -179,8 +170,6 @@ pub struct Facets { #[serde(default)] pub label: Option>, #[serde(default)] - pub has_reminder: Option, - #[serde(default)] pub has_attachment: Option, #[serde(default)] pub created_after: Option, diff --git a/core/src/local/schema.rs b/core/src/local/schema.rs index 38ade58..f0cfe0e 100644 --- a/core/src/local/schema.rs +++ b/core/src/local/schema.rs @@ -325,6 +325,12 @@ const SCHEMA_V12: &str = r#" 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<()> { conn.execute_batch("PRAGMA foreign_keys = ON;")?; 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("PRAGMA user_version = 12;")?; } + if version < 13 { + conn.execute_batch(SCHEMA_V13)?; + conn.execute_batch("PRAGMA user_version = 13;")?; + } Ok(()) } @@ -490,7 +500,7 @@ mod tests { let version: i64 = conn .query_row("PRAGMA user_version", [], |r| r.get(0)) .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 @@ -544,46 +554,17 @@ mod tests { 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] - fn v9_sweeps_colour_out_of_saved_filters() { + fn v13_drops_saved_views() { 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"); - - let read = |id: &str| -> String { - conn.query_row( - "SELECT params FROM saved_filters WHERE id = ?1", - [id], + let tables: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name = 'saved_filters'", + [], |r| r.get(0), ) - .expect("read") - }; - assert_eq!(read("a"), r#"{"q":"milk"}"#); - assert_eq!(read("b"), r#"{"q":"eggs"}"#); - assert_eq!(read("c"), "not json"); + .expect("count"); + assert_eq!(tables, 0); } } diff --git a/core/src/local/store.rs b/core/src/local/store.rs index ef1e9c3..be62322 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -414,9 +414,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result) -> rusqlite::Result { - let params_str: String = r.get(2)?; - Ok(SavedFilter { - id: r.get(0)?, - name: r.get(1)?, - params: serde_json::from_str(¶ms_str).unwrap_or_else(|_| json!({})), - position: r.get(3)?, - }) -} - -pub fn list_saved_filters(conn: &Connection) -> rusqlite::Result> { - 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 { - 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 { - 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)] mod tests { //! The store had no tests of its own until two bugs shipped in it unnoticed: @@ -1557,28 +1485,16 @@ mod tests { } #[test] - fn reminder_and_attachment_facets() { + fn attachment_facet() { let conn = db(); - let reminded = note(&conn, "reminded"); let attached = note(&conn, "attached"); note(&conn, "plain"); - update_note( - &conn, - &reminded.id, - &json!({"remind_at": "2026-12-01T09:00:00.000Z"}), - ) - .expect("remind"); conn.execute( "INSERT INTO attachments (id, note_id, url) VALUES ('a', ?1, '/x')", [&attached.id], ) .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( &conn, json!({"view": "notes", "facets": {"has_attachment": true}}), diff --git a/desktop/src-tauri/src/commands/local.rs b/desktop/src-tauri/src/commands/local.rs index cdffe69..0b439ba 100644 --- a/desktop/src-tauri/src/commands/local.rs +++ b/desktop/src-tauri/src/commands/local.rs @@ -299,35 +299,3 @@ pub fn labels_merge(source_id: String, into: String, db: State<'_, Db>) -> Resul let conn = db.conn()?; store::merge_labels(&conn, &source_id, &into).map_err(|e| e.to_string()) } - -#[tauri::command] -pub fn saved_filters_list(db: State<'_, Db>) -> Result, 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 { - let conn = db.conn()?; - store::create_saved_filter(&conn, &name, ¶ms).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 { - let conn = db.conn()?; - store::rename_saved_filter(&conn, &id, &name).map_err(|e| e.to_string()) -} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 26f9a4f..cccd76c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -200,10 +200,6 @@ pub fn run() { commands::local::labels_set_color, commands::local::labels_remove, 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_link, commands::sync::sync_unlink, diff --git a/frontend/src/adapters/local.ts b/frontend/src/adapters/local.ts index 334a894..67c5aa4 100644 --- a/frontend/src/adapters/local.ts +++ b/frontend/src/adapters/local.ts @@ -11,7 +11,6 @@ import { invoke } from "../desktop/bridge"; import type { Note, NoteRevision } from "../stores/notes"; import type { Label } from "../stores/labels"; -import type { SavedFilter } from "../stores/savedFilters"; import type { Device } from "../stores/devices"; import type { TitleEntry } from "../stores/titles"; import type { User } from "../stores/session"; @@ -82,13 +81,6 @@ export const local: Repo = { titles: () => invoke("notes_titles"), }, - savedFilters: { - list: () => invoke("saved_filters_list"), - create: (name, params) => invoke("saved_filters_create", { name, params }), - remove: (id) => invoke("saved_filters_remove", { id }), - rename: (id, name) => invoke("saved_filters_rename", { id, name }), - }, - // 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. settings: { diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index f83540e..8d19c19 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -11,7 +11,6 @@ import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes"; import type { Label } from "../stores/labels"; -import type { SavedFilter } from "../stores/savedFilters"; import type { Device } from "../stores/devices"; import type { TitleEntry } from "../stores/titles"; import type { User } from "../stores/session"; @@ -190,20 +189,12 @@ export interface SettingsRepo { sendTestEmail(): Promise<{ to: string }>; } -export interface SavedFiltersRepo { - list(): Promise; - create(name: string, params: NoteFacets): Promise; - remove(id: string): Promise; - rename(id: string, name: string): Promise; -} - export interface Repo { config: ConfigRepo; auth: AuthRepo; devices: DevicesRepo; labels: LabelsRepo; notes: NotesRepo; - savedFilters: SavedFiltersRepo; settings: SettingsRepo; shares: SharesRepo; } diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index 2ed7a7d..221e9de 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -7,7 +7,6 @@ import { api } from "../api/client"; import type { Note, NoteRevision } from "../stores/notes"; import type { Label } from "../stores/labels"; -import type { SavedFilter } from "../stores/savedFilters"; import type { Device } from "../stores/devices"; import type { TitleEntry } from "../stores/titles"; import type { User } from "../stores/session"; @@ -34,7 +33,6 @@ function notesQuery(q: NoteListQuery): string { if (q.labelId) params.append("label", q.labelId); 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?.has_reminder) params.set("has_reminder", "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_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, }, - savedFilters: { - list: async () => (await api.get<{ filters: SavedFilter[] }>("/api/saved-filters")).filters, - create: (name, params) => api.post("/api/saved-filters", { name, params }), - remove: (id) => api.del(`/api/saved-filters/${id}`), - rename: (id, name) => api.patch(`/api/saved-filters/${id}`, { name }), - }, - settings: { list: async () => (await api.get<{ settings: ServerSetting[] }>("/api/settings")).settings, save: async (values) => diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 7b11d1f..8da3859 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -4,7 +4,6 @@ import { useRoute, useRouter, type LocationQueryRaw } from "vue-router"; import { useSessionStore } from "../stores/session"; import { useConfigStore } from "../stores/config"; import { useLabelsStore } from "../stores/labels"; -import { useSavedFiltersStore, type SavedFilter } from "../stores/savedFilters"; import { useReminderStore } from "../stores/reminders"; import { useUiStore } from "../stores/ui"; import BaseModal from "./BaseModal.vue"; @@ -16,7 +15,6 @@ import LabelsModal from "./LabelsModal.vue"; import ShareDialog from "./ShareDialog.vue"; import { isDesktop } from "../desktop/bridge"; import { useLensName } from "../composables/useLensName"; -import { facetsToQuery } from "../notes/facets"; import { labelDotClasses } from "../notes/colors"; const route = useRoute(); @@ -24,7 +22,6 @@ const router = useRouter(); const session = useSessionStore(); const config = useConfigStore(); const labels = useLabelsStore(); -const savedFilters = useSavedFiltersStore(); const reminders = useReminderStore(); const ui = useUiStore(); // 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}`, ); -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 showShortcuts = ref(false); const paletteOpen = ref(false); @@ -182,7 +170,6 @@ function onKeydown(e: KeyboardEvent) { onMounted(() => { if (!labels.loaded) void labels.load(); - if (!savedFilters.loaded) void savedFilters.load(); reminders.start(); // foreground reminder delivery while the app is open 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 // 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( () => route.query.q, (q) => { @@ -435,29 +422,6 @@ async function signOut() { {{ lb.name }} - - Archive diff --git a/frontend/src/components/FilterBar.vue b/frontend/src/components/FilterBar.vue index 53d8922..d96a5e9 100644 --- a/frontend/src/components/FilterBar.vue +++ b/frontend/src/components/FilterBar.vue @@ -2,22 +2,16 @@ import { computed, ref } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useLabelsStore } from "../stores/labels"; -import { useSavedFiltersStore } from "../stores/savedFilters"; -import { useUiStore } from "../stores/ui"; import type { NoteFacets } from "../stores/notes"; import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets"; -import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime"; import Icon from "./Icon.vue"; -import { errorMessage } from "../api/errors"; -// A dead-simple facet bar over the board: color + labels + has-reminder -// + has-attachment + created-date range. The URL query IS the state, so a -// filtered board is a shareable lens and a saved view is just a link. +// A dead-simple facet bar over the board: tags, has-attachment and shared-with-me. +// The URL query IS the state, so a filtered board is a shareable link. Reminders +// and dates have their own lenses (Reminders, Timeline). const route = useRoute(); const router = useRouter(); const labels = useLabelsStore(); -const savedFilters = useSavedFiltersStore(); -const ui = useUiStore(); const open = ref(false); const facets = computed(() => facetsFromQuery(route.query)); @@ -37,9 +31,6 @@ function toggleLabel(id: string) { const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]; patch({ label: next.length ? next : undefined }); } -function toggleReminder() { - patch({ has_reminder: facets.value.has_reminder ? undefined : true }); -} function toggleAttachment() { patch({ has_attachment: facets.value.has_attachment ? undefined : true }); } @@ -47,38 +38,6 @@ function toggleShared() { 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 = "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"; @@ -97,14 +56,14 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b Filters {{ count }} - +
Only - @@ -140,23 +96,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b Shared with me
- -
- Created - - to - -
diff --git a/frontend/src/notes/facets.test.ts b/frontend/src/notes/facets.test.ts new file mode 100644 index 0000000..431ed0e --- /dev/null +++ b/frontend/src/notes/facets.test.ts @@ -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); + }); +}); diff --git a/frontend/src/notes/facets.ts b/frontend/src/notes/facets.ts index 138c2dd..888796f 100644 --- a/frontend/src/notes/facets.ts +++ b/frontend/src/notes/facets.ts @@ -1,5 +1,6 @@ // 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 { NoteFacets } from "../stores/notes"; @@ -16,12 +17,7 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets { const text = one(q.q); if (text) f.q = text; 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; - 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"; return f; } @@ -30,10 +26,7 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw { const q: LocationQueryRaw = {}; if (f.q) q.q = f.q; 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.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; return q; } @@ -43,9 +36,7 @@ export function facetCount(f: NoteFacets): number { let n = 0; if (f.q) n++; n += f.label?.length ?? 0; - if (f.has_reminder) n++; if (f.has_attachment) n++; - if (f.created_after || f.created_before) n++; if (f.shared) n++; return n; } diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index b3b282a..9a7af2c 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -4,12 +4,11 @@ import { repo } from "../adapters"; import { useUiStore } from "./ui"; export type NoteView = "active" | "archived" | "trash"; -// Combinable facet filters for the board (mirrors the GET /api/notes query + a saved -// view's stored params). All optional; empty = the plain, unfiltered board. +// 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 { q?: string; label?: string[]; - has_reminder?: boolean; has_attachment?: boolean; created_after?: string; created_before?: string; diff --git a/frontend/src/stores/savedFilters.ts b/frontend/src/stores/savedFilters.ts deleted file mode 100644 index 4afb311..0000000 --- a/frontend/src/stores/savedFilters.ts +++ /dev/null @@ -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([]); - const loaded = ref(false); - - async function load(): Promise { - try { - items.value = await repo.savedFilters.list(); - } catch { - // leave whatever we have - } finally { - loaded.value = true; - } - } - - async function create(name: string, params: NoteFacets): Promise { - const sf = await repo.savedFilters.create(name, params); - items.value.push(sf); - return sf; - } - - async function remove(id: string): Promise { - await repo.savedFilters.remove(id); - items.value = items.value.filter((f) => f.id !== id); - } - - async function rename(id: string, name: string): Promise { - 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 }; -}); diff --git a/src/inkwell/app.py b/src/inkwell/app.py index 4b32c6b..ff8c83e 100644 --- a/src/inkwell/app.py +++ b/src/inkwell/app.py @@ -22,7 +22,6 @@ from .labels import bp as labels_bp from .notes import bp as notes_bp from .proxy import is_https 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_api import bp as settings_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(shares_bp) app.register_blueprint(sync_bp) - app.register_blueprint(saved_filters_bp) app.register_blueprint(client_bp) @app.before_serving diff --git a/src/inkwell/colors.py b/src/inkwell/colors.py index 64900a1..96fa9fd 100644 --- a/src/inkwell/colors.py +++ b/src/inkwell/colors.py @@ -5,7 +5,7 @@ from __future__ import annotations # 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 # 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 # and NoteTint.kt), so they can be retuned without a schema migration — which M315 spent diff --git a/src/inkwell/common.py b/src/inkwell/common.py index 69d93cc..424370a 100644 --- a/src/inkwell/common.py +++ b/src/inkwell/common.py @@ -31,7 +31,7 @@ def parse_dt(raw: object) -> datetime | None: def coerce_bool(raw: object) -> 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 ordinary truthiness. """ diff --git a/src/inkwell/models/all.py b/src/inkwell/models/all.py index 7515ebc..7aa4a18 100644 --- a/src/inkwell/models/all.py +++ b/src/inkwell/models/all.py @@ -14,7 +14,6 @@ from . import ( # noqa: F401 note_revision, note_user_state, password_reset, - saved_filter, settings, share, share_revocation, diff --git a/src/inkwell/models/saved_filter.py b/src/inkwell/models/saved_filter.py deleted file mode 100644 index c68c037..0000000 --- a/src/inkwell/models/saved_filter.py +++ /dev/null @@ -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()) diff --git a/src/inkwell/notes/__init__.py b/src/inkwell/notes/__init__.py index c4da491..f49fb15 100644 --- a/src/inkwell/notes/__init__.py +++ b/src/inkwell/notes/__init__.py @@ -102,10 +102,9 @@ async def list_notes(): filter_name = request.args.get("filter", "active") if filter_name not in VALID_FILTERS: return json_error("invalid filter", 400) - # Combinable facet filters (all optional, AND-ed together) — the rich-search / - # saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them. + # Combinable facet filters (all optional, AND-ed together) — the Filters panel. + # Multiple ?label= narrow to notes carrying ALL of them. label_params = request.args.getlist("label") - has_reminder = coerce_bool(request.args.get("has_reminder")) has_attachment = coerce_bool(request.args.get("has_attachment")) query_text = (request.args.get("q") or "").strip() # Optional creation-date range — the "browse by when" / Timeline lens. Both bounds @@ -137,8 +136,6 @@ async def list_notes(): if lid is None: return json_error("invalid label", 400) 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: stmt = stmt.where(Note.id.in_(select(NoteAttachment.note_id))) if after_param: diff --git a/src/inkwell/saved_filters.py b/src/inkwell/saved_filters.py deleted file mode 100644 index 8fc0085..0000000 --- a/src/inkwell/saved_filters.py +++ /dev/null @@ -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("/") -@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("/") -@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}) diff --git a/tests/test_notes.py b/tests/test_notes.py index f362243..fe695ee 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -59,8 +59,8 @@ def test_all_note_routes_registered(app): } assert expected <= registered, f"unregistered note routes: {expected - registered}" # Removed in #5178: an item is added, reworded and removed by editing the body, - # and previews arrive in the background after a save. - gone = {"notes.add_item", "notes.delete_item", "notes.unfurl_link"} + # and previews arrive in the background after a save. Saved views went in #5180. + 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}" diff --git a/tests/test_saved_filters.py b/tests/test_saved_filters.py deleted file mode 100644 index d766a4c..0000000 --- a/tests/test_saved_filters.py +++ /dev/null @@ -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