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",