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