0.2.0 — a notebook in your pocket, ready to be hosted #3
@@ -190,12 +190,6 @@ pub fn notes_titles(db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
|
|||||||
store::titles(&conn).map_err(|e| e.to_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]
|
#[tauri::command]
|
||||||
pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, String> {
|
pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, String> {
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ pub fn run() {
|
|||||||
commands::local::notes_restore_revision,
|
commands::local::notes_restore_revision,
|
||||||
commands::local::notes_reminders,
|
commands::local::notes_reminders,
|
||||||
commands::local::notes_titles,
|
commands::local::notes_titles,
|
||||||
commands::local::notes_search,
|
|
||||||
commands::local::labels_list,
|
commands::local::labels_list,
|
||||||
commands::local::labels_create,
|
commands::local::labels_create,
|
||||||
commands::local::labels_rename,
|
commands::local::labels_rename,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning
|
// parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning
|
||||||
// yet (account auth, device linking, attachment upload, URL unfurl, file import) —
|
// yet (account auth, device linking, attachment upload, URL unfurl, file import) —
|
||||||
// those reject with a clear message rather than silently failing; the board, editor,
|
// 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 { invoke } from "../desktop/bridge";
|
||||||
import type { Note, NoteRevision } from "../stores/notes";
|
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 }),
|
restoreRevision: (id, revId) => invoke<Note>("notes_restore_revision", { id, revId }),
|
||||||
reminders: () => invoke<Note[]>("notes_reminders"),
|
reminders: () => invoke<Note[]>("notes_reminders"),
|
||||||
titles: () => invoke<TitleEntry[]>("notes_titles"),
|
titles: () => invoke<TitleEntry[]>("notes_titles"),
|
||||||
search: (q) => invoke<Note[]>("notes_search", { q }),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
savedFilters: {
|
savedFilters: {
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ export interface NotesRepo {
|
|||||||
restoreRevision(id: string, revId: string): Promise<Note>;
|
restoreRevision(id: string, revId: string): Promise<Note>;
|
||||||
reminders(): Promise<Note[]>;
|
reminders(): Promise<Note[]>;
|
||||||
titles(): Promise<TitleEntry[]>;
|
titles(): Promise<TitleEntry[]>;
|
||||||
search(q: string): Promise<Note[]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedFiltersRepo {
|
export interface SavedFiltersRepo {
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export const rest: Repo = {
|
|||||||
restoreRevision: (id, revId) => api.post<Note>(`/api/notes/${id}/revisions/${revId}/restore`),
|
restoreRevision: (id, revId) => api.post<Note>(`/api/notes/${id}/revisions/${revId}/restore`),
|
||||||
reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes,
|
reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes,
|
||||||
titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles,
|
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: {
|
savedFilters: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
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 { useSessionStore } from "../stores/session";
|
||||||
import { useConfigStore } from "../stores/config";
|
import { useConfigStore } from "../stores/config";
|
||||||
import { useLabelsStore } from "../stores/labels";
|
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;
|
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) {
|
function onSearch(value: string) {
|
||||||
searchText.value = value;
|
searchText.value = value;
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout(() => {
|
searchTimer = setTimeout(() => {
|
||||||
const q = searchText.value.trim();
|
const q = searchText.value.trim();
|
||||||
if (q) router.push({ name: "search", query: { q } });
|
const onBoard = BOARD_ROUTES.has(String(route.name));
|
||||||
else if (route.name === "search") router.push("/");
|
const query: LocationQueryRaw = onBoard ? { ...route.query } : {};
|
||||||
|
if (q) query.q = q;
|
||||||
|
else delete query.q;
|
||||||
|
void router.push({ path: onBoard ? route.path : "/", query });
|
||||||
}, 250);
|
}, 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(
|
watch(
|
||||||
() => route.name,
|
() => route.name,
|
||||||
(name) => {
|
() => {
|
||||||
if (name !== "search") searchText.value = "";
|
|
||||||
drawer.value = false;
|
drawer.value = false;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -200,7 +227,7 @@ watch(
|
|||||||
* What to call the lens currently in view.
|
* What to call the lens currently in view.
|
||||||
*
|
*
|
||||||
* Keyed off the route name rather than each view declaring its own title, so the
|
* 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).
|
* 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
|
* 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";
|
return "Archive";
|
||||||
case "trash":
|
case "trash":
|
||||||
return "Trash";
|
return "Trash";
|
||||||
case "search":
|
|
||||||
return "Search";
|
|
||||||
case "timeline":
|
case "timeline":
|
||||||
return "Timeline";
|
return "Timeline";
|
||||||
case "reminders":
|
case "reminders":
|
||||||
@@ -286,7 +311,7 @@ async function signOut() {
|
|||||||
page you navigated to — so it sits in the bar that never moves, beside
|
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
|
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
|
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">
|
<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
|
<!-- 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
|
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
|
BoardView, so keying on the route would remount it — blanking the board
|
||||||
and refetching, which is precisely the page-change feeling this is meant
|
and refetching, which is precisely the page-change feeling this is meant
|
||||||
to remove. Unkeyed, Vue only transitions when the component TYPE changes
|
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. -->
|
lenses stays an in-place reflow that NoteGrid animates. -->
|
||||||
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
|
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component }">
|
||||||
|
|||||||
@@ -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 { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
import Icon from "./Icon.vue";
|
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
|
// + 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.
|
// filtered board is a shareable lens and a saved view is just a link.
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -47,13 +47,6 @@ function toggleAttachment() {
|
|||||||
patch({ has_attachment: facets.value.has_attachment ? undefined : true });
|
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) {
|
function onFrom(e: Event) {
|
||||||
const v = (e.target as HTMLInputElement).value;
|
const v = (e.target as HTMLInputElement).value;
|
||||||
patch({ created_after: v ? `${v}T00:00:00` : undefined });
|
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"
|
v-if="open"
|
||||||
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
|
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">
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
|
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const router = createRouter({
|
|||||||
{ path: "archive", name: "archive", component: () => import("../views/BoardView.vue") },
|
{ path: "archive", name: "archive", component: () => import("../views/BoardView.vue") },
|
||||||
{ path: "trash", name: "trash", 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: "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: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
|
||||||
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -141,8 +141,9 @@ async def list_notes():
|
|||||||
stmt = stmt.where(Note.created_at < before_dt)
|
stmt = stmt.where(Note.created_at < before_dt)
|
||||||
if query_text:
|
if query_text:
|
||||||
# Full-text match over the note's name + body (generated tsvector,
|
# Full-text match over the note's name + body (generated tsvector,
|
||||||
# migrations 0005/0026), ranked — so the facet bar's text box searches,
|
# migrations 0005/0026), ranked. This is the ONLY text search now: the
|
||||||
# not just filters.
|
# 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)
|
tsquery = func.websearch_to_tsquery("english", query_text)
|
||||||
search_col = literal_column("notes.search_vector")
|
search_col = literal_column("notes.search_vector")
|
||||||
stmt = stmt.where(search_col.op("@@")(tsquery)).order_by(
|
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)})
|
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")
|
@bp.get("/reminders")
|
||||||
@login_required
|
@login_required
|
||||||
async def list_reminders():
|
async def list_reminders():
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ def test_all_note_routes_registered(app):
|
|||||||
expected = {
|
expected = {
|
||||||
f"notes.{name}"
|
f"notes.{name}"
|
||||||
for name in (
|
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",
|
"snooze_reminder", "export_notes", "import_notes", "list_titles",
|
||||||
"reorder_notes", "create_note",
|
"reorder_notes", "create_note",
|
||||||
"get_note", "update_note", "list_revisions", "restore_revision",
|
"get_note", "update_note", "list_revisions", "restore_revision",
|
||||||
|
|||||||
Reference in New Issue
Block a user