0.2.0 — a notebook in your pocket, ready to be hosted #3

Merged
bvandeusen merged 22 commits from dev into main 2026-08-23 16:38:00 -04:00
11 changed files with 42 additions and 114 deletions
Showing only changes of commit 7033995975 - Show all commits
-6
View File
@@ -190,12 +190,6 @@ pub fn notes_titles(db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
store::titles(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_search(q: String, db: State<'_, Db>) -> Result<Vec<Note>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::search(&conn, &q).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
-1
View File
@@ -119,7 +119,6 @@ pub fn run() {
commands::local::notes_restore_revision,
commands::local::notes_reminders,
commands::local::notes_titles,
commands::local::notes_search,
commands::local::labels_list,
commands::local::labels_create,
commands::local::labels_rename,
+1 -2
View File
@@ -6,7 +6,7 @@
// parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning
// yet (account auth, device linking, attachment upload, URL unfurl, file import) —
// those reject with a clear message rather than silently failing; the board, editor,
// capture, search, filters, labels, checklists and reminders all work fully offline.
// capture, filters, labels, checklists and reminders all work fully offline.
import { invoke } from "../desktop/bridge";
import type { Note, NoteRevision } from "../stores/notes";
@@ -71,7 +71,6 @@ export const local: Repo = {
restoreRevision: (id, revId) => invoke<Note>("notes_restore_revision", { id, revId }),
reminders: () => invoke<Note[]>("notes_reminders"),
titles: () => invoke<TitleEntry[]>("notes_titles"),
search: (q) => invoke<Note[]>("notes_search", { q }),
},
savedFilters: {
-1
View File
@@ -111,7 +111,6 @@ export interface NotesRepo {
restoreRevision(id: string, revId: string): Promise<Note>;
reminders(): Promise<Note[]>;
titles(): Promise<TitleEntry[]>;
search(q: string): Promise<Note[]>;
}
export interface SavedFiltersRepo {
-1
View File
@@ -99,7 +99,6 @@ export const rest: Repo = {
restoreRevision: (id, revId) => api.post<Note>(`/api/notes/${id}/revisions/${revId}/restore`),
reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes,
titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles,
search: async (q) => (await api.get<{ notes: Note[] }>(`/api/notes/search?q=${encodeURIComponent(q)}`)).notes,
},
savedFilters: {
+36 -11
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config";
import { useLabelsStore } from "../stores/labels";
@@ -177,21 +177,48 @@ function labelDot(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
}
// The board lenses — the routes a search can happen *within*. Searching while looking
// at Trash should search Trash, not silently move you.
const BOARD_ROUTES = new Set(["board", "archive", "trash", "label"]);
/**
* Search is a FACET, not a destination.
*
* It used to navigate to a `/search` view backed by a different endpoint with no
* facets at all — so the one screen you landed on when you searched was the one
* screen where you could not also narrow by tag, which is precisely what tags are
* for (note 2930). Now it writes `?q=` into the board's URL, beside any labels
* already there, and the same AND-ed query serves both.
*
* Existing facets are preserved, so "filter by #grocery, then search" and the reverse
* both work.
*/
function onSearch(value: string) {
searchText.value = value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
const q = searchText.value.trim();
if (q) router.push({ name: "search", query: { q } });
else if (route.name === "search") router.push("/");
const onBoard = BOARD_ROUTES.has(String(route.name));
const query: LocationQueryRaw = onBoard ? { ...route.query } : {};
if (q) query.q = q;
else delete query.q;
void router.push({ path: onBoard ? route.path : "/", query });
}, 250);
}
// Clear the search box when navigating to a non-search view.
// 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.
watch(
() => route.query.q,
(q) => {
searchText.value = typeof q === "string" ? q : "";
},
{ immediate: true },
);
watch(
() => route.name,
(name) => {
if (name !== "search") searchText.value = "";
() => {
drawer.value = false;
},
);
@@ -200,7 +227,7 @@ watch(
* What to call the lens currently in view.
*
* Keyed off the route name rather than each view declaring its own title, so the
* label sits in one place and can't go missing (the board and search never had one)
* label sits in one place and can't go missing (the board never had one)
* or drift in styling (timeline and reminders each had their own h1).
*
* A label lens is named by the label itself — "Groceries" is what the user came
@@ -212,8 +239,6 @@ const lensName = computed<string>(() => {
return "Archive";
case "trash":
return "Trash";
case "search":
return "Search";
case "timeline":
return "Timeline";
case "reminders":
@@ -286,7 +311,7 @@ async function signOut() {
page you navigated to — so it sits in the bar that never moves, beside
the app name, and stays in one place while everything beneath it
re-filters. Replaces the per-view <h1>s, which sat in a different spot
in each view and were absent entirely on the board and in search. -->
in each view and were absent entirely on the board. -->
<span aria-live="polite" class="flex min-w-0 shrink items-center gap-2 text-sm text-neutral-400">
<!-- The separator only makes sense next to the app name, which is itself
hidden on narrow screens. There, the lens name simply takes the space
@@ -511,7 +536,7 @@ async function signOut() {
BoardView, so keying on the route would remount it — blanking the board
and refetching, which is precisely the page-change feeling this is meant
to remove. Unkeyed, Vue only transitions when the component TYPE changes
(board ↔ search ↔ timeline), and moving between the board's own
(board ↔ timeline ↔ reminders), and moving between the board's own
lenses stays an in-place reflow that NoteGrid animates. -->
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
<RouterView v-slot="{ Component }">
+1 -16
View File
@@ -10,7 +10,7 @@ import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime"
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import Icon from "./Icon.vue";
// A dead-simple facet bar over the board: text search + color + labels + has-reminder
// 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.
const route = useRoute();
@@ -47,13 +47,6 @@ function toggleAttachment() {
patch({ has_attachment: facets.value.has_attachment ? undefined : true });
}
let qTimer: ReturnType<typeof setTimeout> | undefined;
function onQ(e: Event) {
const v = (e.target as HTMLInputElement).value;
clearTimeout(qTimer);
qTimer = setTimeout(() => patch({ q: v.trim() || undefined }), 300);
}
function onFrom(e: Event) {
const v = (e.target as HTMLInputElement).value;
patch({ created_after: v ? `${v}T00:00:00` : undefined });
@@ -118,14 +111,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
v-if="open"
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
>
<input
type="search"
:value="facets.q ?? ''"
placeholder="Search text…"
class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
@input="onQ"
/>
<div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
<button
-1
View File
@@ -20,7 +20,6 @@ const router = createRouter({
{ path: "archive", name: "archive", component: () => import("../views/BoardView.vue") },
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
],
-48
View File
@@ -1,48 +0,0 @@
<script setup lang="ts">
import { computed, watch } from "vue";
import { useRoute } from "vue-router";
import { repo } from "../adapters";
import { useNoteList } from "../composables/useNoteList";
import { useNoteEditor } from "../composables/useNoteEditor";
import AsyncState from "../components/AsyncState.vue";
import EmptyState from "../components/EmptyState.vue";
import NoteGrid from "../components/NoteGrid.vue";
import NoteEditor from "../components/NoteEditor.vue";
const route = useRoute();
const query = computed(() => (typeof route.query.q === "string" ? route.query.q : ""));
const noMatchSubtitle = computed(() => `Nothing found for "${query.value}".`);
const { items: results, loading, error, load: run } = useNoteList(async () => {
const q = query.value.trim();
if (!q) return [];
return repo.notes.search(q);
}, "Search failed.");
const { editing, open: openEditor, close: closeEditor, navigate: onNavigate } = useNoteEditor({
list: () => results.value,
onClose: run, // reflect any edits made from a result
});
watch(query, run, { immediate: true });
</script>
<template>
<div class="mx-auto w-full max-w-6xl px-4 py-6">
<p class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
<template v-if="query"
>Results for <span class="font-semibold text-neutral-800 dark:text-neutral-200">{{ query }}</span></template
>
<template v-else>Type in the search box to find your notes.</template>
</p>
<AsyncState :loading="loading" :error="error || undefined" error-title="Couldn't search" @retry="run">
<EmptyState v-if="query && results.length === 0" title="No matches" :subtitle="noMatchSubtitle" />
<NoteGrid v-else-if="results.length" :notes="results" @open="openEditor" />
</AsyncState>
</div>
<template v-if="editing">
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
</template>
</template>
+3 -26
View File
@@ -141,8 +141,9 @@ async def list_notes():
stmt = stmt.where(Note.created_at < before_dt)
if query_text:
# Full-text match over the note's name + body (generated tsvector,
# migrations 0005/0026), ranked — so the facet bar's text box searches,
# not just filters.
# migrations 0005/0026), ranked. This is the ONLY text search now: the
# separate facet-less `/search` route was removed because landing on it
# was the one place you could not also narrow by tag (note 2930).
tsquery = func.websearch_to_tsquery("english", query_text)
search_col = literal_column("notes.search_vector")
stmt = stmt.where(search_col.op("@@")(tsquery)).order_by(
@@ -156,30 +157,6 @@ async def list_notes():
return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.get("/search")
@login_required
async def search_notes():
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"notes": []})
async with session_scope() as db:
tsquery = func.websearch_to_tsquery("english", q)
# search_vector is a generated column (migration 0005), not mapped on the ORM.
search_col = literal_column("notes.search_vector")
stmt = (
select(Note)
.where(
visible_to_user("note", Note.owner_id, Note.id, g.user_id),
Note.deleted_at.is_(None),
search_col.op("@@")(tsquery),
)
.order_by(func.ts_rank(search_col, tsquery).desc(), Note.updated_at.desc())
.limit(100)
)
notes = (await db.scalars(stmt)).all()
return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.get("/reminders")
@login_required
async def list_reminders():
+1 -1
View File
@@ -37,7 +37,7 @@ def test_all_note_routes_registered(app):
expected = {
f"notes.{name}"
for name in (
"list_notes", "search_notes", "list_reminders", "complete_reminder",
"list_notes", "list_reminders", "complete_reminder",
"snooze_reminder", "export_notes", "import_notes", "list_titles",
"reorder_notes", "create_note",
"get_note", "update_note", "list_revisions", "restore_revision",