AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -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"])
|
||||
@@ -369,7 +369,6 @@ pub struct NoteQuery {
|
||||
pub struct NoteFacets {
|
||||
pub q: Option<String>,
|
||||
pub label: Option<Vec<String>>,
|
||||
pub has_reminder: Option<bool>,
|
||||
pub has_attachment: Option<bool>,
|
||||
pub created_after: Option<String>,
|
||||
pub created_before: Option<String>,
|
||||
@@ -399,7 +398,6 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
let NoteFacets {
|
||||
q,
|
||||
label,
|
||||
has_reminder,
|
||||
has_attachment,
|
||||
created_after,
|
||||
created_before,
|
||||
@@ -408,7 +406,6 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
core_models::Facets {
|
||||
q,
|
||||
label,
|
||||
has_reminder,
|
||||
has_attachment,
|
||||
created_after,
|
||||
created_before,
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub has_reminder: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub has_attachment: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub created_after: Option<String>,
|
||||
|
||||
+18
-37
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-85
@@ -414,9 +414,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
|
||||
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- 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(¶ms_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)]
|
||||
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}}),
|
||||
|
||||
@@ -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<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, ¶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<SavedFilter, String> {
|
||||
let conn = db.conn()?;
|
||||
store::rename_saved_filter(&conn, &id, &name).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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).
|
||||
// Unlinked, each rejects with the core's explanation, which the dialog shows.
|
||||
settings: {
|
||||
|
||||
@@ -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<SavedFilter[]>;
|
||||
create(name: string, params: NoteFacets): Promise<SavedFilter>;
|
||||
remove(id: string): Promise<void>;
|
||||
rename(id: string, name: string): Promise<SavedFilter>;
|
||||
}
|
||||
|
||||
export interface Repo {
|
||||
config: ConfigRepo;
|
||||
auth: AuthRepo;
|
||||
devices: DevicesRepo;
|
||||
labels: LabelsRepo;
|
||||
notes: NotesRepo;
|
||||
savedFilters: SavedFiltersRepo;
|
||||
settings: SettingsRepo;
|
||||
shares: SharesRepo;
|
||||
}
|
||||
|
||||
@@ -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<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: {
|
||||
list: async () => (await api.get<{ settings: ServerSetting[] }>("/api/settings")).settings,
|
||||
save: async (values) =>
|
||||
|
||||
@@ -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() {
|
||||
<span class="truncate">{{ lb.name }}</span>
|
||||
</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">
|
||||
<Icon name="archive" /> Archive
|
||||
</RouterLink>
|
||||
|
||||
@@ -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<NoteFacets>(() => 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
|
||||
<Icon name="filter" /> Filters
|
||||
<span v-if="count" class="rounded-full bg-brand px-1.5 text-xs font-semibold text-black">{{ count }}</span>
|
||||
</button>
|
||||
<template v-if="count">
|
||||
<button type="button" class="text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300" @click="clearAll">
|
||||
Clear
|
||||
</button>
|
||||
<button type="button" class="text-xs font-medium text-brand-700 underline dark:text-brand" @click="saveView">
|
||||
Save view
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-if="count"
|
||||
type="button"
|
||||
class="text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300"
|
||||
@click="clearAll"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</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">
|
||||
<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">
|
||||
Has attachment
|
||||
</button>
|
||||
@@ -140,23 +96,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
|
||||
Shared with me
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -14,7 +14,6 @@ from . import ( # noqa: F401
|
||||
note_revision,
|
||||
note_user_state,
|
||||
password_reset,
|
||||
saved_filter,
|
||||
settings,
|
||||
share,
|
||||
share_revocation,
|
||||
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user