desktop M10.3: frontend data-source adapter seam (repo interface + rest.ts)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 29s

Extract a typed repository interface (adapters/repo.ts) from the scattered
store/view -> api.* calls, backed by adapters/rest.ts (verbatim HTTP mapping)
and selected through adapters/index.ts. Every store and the notes-facing views
now depend on `repo`, never the HTTP client directly -- the seam the offline
local source (M10.5, over Tauri invoke) plugs into next.

Behavior-preserving for web: rest.ts maps each semantic method to the exact
endpoint the code called before; query-string and multipart building moved out
of the stores/views into rest.ts (the one place that knows the URL shape).
Client-side logic (reconcile/sort/optimistic reorder/toasts) stays in the
stores. GraphView + admin SettingsView keep direct api calls -- out of the
offline-core scope (M10.5 is board/editor/capture/search/filter/labels/
checklists/reminders).

Task 1992.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-24 22:31:00 -04:00
co-authored by Claude Opus 4.8
parent b08cdb92b5
commit 20cf15c99c
14 changed files with 346 additions and 90 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import { repo } from "../adapters";
interface PublicConfig {
export interface PublicConfig {
site_name: string;
allow_registration: boolean;
version: string;
@@ -20,7 +20,7 @@ export const useConfigStore = defineStore("config", () => {
async function load(): Promise<void> {
if (loaded.value) return;
try {
const cfg = await api.get<PublicConfig>("/api/config");
const cfg = await repo.config.get();
siteName.value = cfg.site_name;
allowRegistration.value = cfg.allow_registration;
version.value = cfg.version;
+4 -4
View File
@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import { repo } from "../adapters";
// A linked native client (Tauri/Android) that holds a device bearer token.
export interface Device {
@@ -17,7 +17,7 @@ export const useDevicesStore = defineStore("devices", () => {
async function load(): Promise<void> {
loading.value = true;
try {
items.value = (await api.get<{ devices: Device[] }>("/api/auth/devices")).devices;
items.value = await repo.devices.list();
} finally {
loading.value = false;
}
@@ -26,13 +26,13 @@ export const useDevicesStore = defineStore("devices", () => {
// Issues a token for the current user; the plaintext token is returned ONCE
// (never retrievable again) for the caller to display + copy.
async function create(name: string): Promise<string> {
const res = await api.post<{ token: string; device: Device }>("/api/auth/devices", { name });
const res = await repo.devices.create(name);
items.value.unshift(res.device);
return res.token;
}
async function revoke(id: string): Promise<void> {
await api.del(`/api/auth/devices/${id}`);
await repo.devices.remove(id);
items.value = items.value.filter((d) => d.id !== id);
}
+7 -8
View File
@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import { repo } from "../adapters";
export interface Label {
id: string;
@@ -19,13 +19,12 @@ export const useLabelsStore = defineStore("labels", () => {
}
async function load(): Promise<void> {
const res = await api.get<{ labels: Label[] }>("/api/labels");
items.value = res.labels;
items.value = await repo.labels.list();
loaded.value = true;
}
async function create(name: string): Promise<Label> {
const label = await api.post<Label>("/api/labels", { name });
const label = await repo.labels.create(name);
if (!items.value.some((lb) => lb.id === label.id)) {
items.value.push(label);
sort();
@@ -34,7 +33,7 @@ export const useLabelsStore = defineStore("labels", () => {
}
async function rename(id: string, name: string): Promise<void> {
const updated = await api.patch<Label>(`/api/labels/${id}`, { name });
const updated = await repo.labels.rename(id, name);
const idx = items.value.findIndex((lb) => lb.id === id);
// The single-label PATCH doesn't recompute the count — keep the one we have.
if (idx >= 0) items.value[idx] = { ...updated, count: items.value[idx].count };
@@ -42,20 +41,20 @@ export const useLabelsStore = defineStore("labels", () => {
}
async function setColor(id: string, color: string): Promise<void> {
const updated = await api.patch<Label>(`/api/labels/${id}`, { color });
const updated = await repo.labels.setColor(id, color);
const idx = items.value.findIndex((lb) => lb.id === id);
if (idx >= 0) items.value[idx] = { ...updated, count: items.value[idx].count };
}
async function remove(id: string): Promise<void> {
await api.del(`/api/labels/${id}`);
await repo.labels.remove(id);
items.value = items.value.filter((lb) => lb.id !== id);
}
// Merge `sourceId` into `targetId`: the server moves the source's notes onto the
// target and deletes the source; the response carries the target's new count.
async function mergeInto(sourceId: string, targetId: string): Promise<void> {
const target = await api.post<Label>(`/api/labels/${sourceId}/merge`, { into: targetId });
const target = await repo.labels.merge(sourceId, targetId);
items.value = items.value.filter((lb) => lb.id !== sourceId);
const idx = items.value.findIndex((lb) => lb.id === targetId);
if (idx >= 0) items.value[idx] = target;
+23 -40
View File
@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import { repo } from "../adapters";
import { useUiStore } from "./ui";
import type { NoteColor } from "../notes/colors";
@@ -127,19 +127,7 @@ export const useNotesStore = defineStore("notes", () => {
activeFacets.value = facets;
loading.value = true;
try {
const params = new URLSearchParams();
params.set("filter", v);
if (labelId) params.append("label", labelId);
for (const id of facets.label ?? []) if (id) params.append("label", id);
if (facets.q) params.set("q", facets.q);
if (facets.color) params.set("color", facets.color);
if (facets.kind) params.set("kind", facets.kind);
if (facets.has_reminder) params.set("has_reminder", "true");
if (facets.has_attachment) params.set("has_attachment", "true");
if (facets.created_after) params.set("created_after", facets.created_after);
if (facets.created_before) params.set("created_before", facets.created_before);
const res = await api.get<{ notes: Note[] }>(`/api/notes?${params.toString()}`);
items.value = res.notes;
items.value = await repo.notes.list({ view: v, labelId, facets });
sortItems();
} finally {
loading.value = false;
@@ -153,7 +141,7 @@ export const useNotesStore = defineStore("notes", () => {
kind?: NoteKind;
items?: string[];
}): Promise<Note> {
const note = await api.post<Note>("/api/notes", input);
const note = await repo.notes.create(input);
reconcile(note);
return note;
}
@@ -164,7 +152,7 @@ export const useNotesStore = defineStore("notes", () => {
Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at" | "recurrence">
>,
): Promise<void> {
reconcile(await api.patch<Note>(`/api/notes/${id}`, changes));
reconcile(await repo.notes.update(id, changes));
}
const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned });
@@ -180,50 +168,46 @@ export const useNotesStore = defineStore("notes", () => {
const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
async function completeReminder(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/reminder/complete`));
reconcile(await repo.notes.completeReminder(id));
}
async function snoozeReminder(id: string, minutes: number): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }));
reconcile(await repo.notes.snoozeReminder(id, minutes));
}
async function setLabels(id: string, labelIds: string[]): Promise<void> {
reconcile(await api.put<Note>(`/api/notes/${id}/labels`, { label_ids: labelIds }));
reconcile(await repo.notes.setLabels(id, labelIds));
}
async function addItem(id: string, text: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/items`, { text }));
reconcile(await repo.notes.addItem(id, text));
}
async function updateItem(id: string, itemId: string, changes: { text?: string; checked?: boolean }): Promise<void> {
reconcile(await api.patch<Note>(`/api/notes/${id}/items/${itemId}`, changes));
reconcile(await repo.notes.updateItem(id, itemId, changes));
}
async function deleteItem(id: string, itemId: string): Promise<void> {
reconcile(await api.del<Note>(`/api/notes/${id}/items/${itemId}`));
reconcile(await repo.notes.deleteItem(id, itemId));
}
async function uploadAttachment(id: string, file: File): Promise<void> {
const form = new FormData();
form.append("file", file);
reconcile(await api.postForm<Note>(`/api/notes/${id}/attachments`, form));
reconcile(await repo.notes.uploadAttachment(id, file));
}
async function deleteAttachment(id: string, attId: string): Promise<void> {
reconcile(await api.del<Note>(`/api/notes/${id}/attachments/${attId}`));
reconcile(await repo.notes.deleteAttachment(id, attId));
}
async function unfurl(id: string, url: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/unfurl`, { url }));
reconcile(await repo.notes.unfurl(id, url));
}
async function deletePreview(id: string, previewId: string): Promise<void> {
reconcile(await api.del<Note>(`/api/notes/${id}/previews/${previewId}`));
reconcile(await repo.notes.deletePreview(id, previewId));
}
async function importNotes(file: File): Promise<{ source: string; imported: number; skipped: number }> {
const form = new FormData();
form.append("file", file);
const data = await api.postForm<{ source: string; imported: number; skipped: number }>("/api/notes/import", form);
const data = await repo.notes.import(file);
// Refresh the current lens so imported notes appear (labels reloaded by caller).
await load(view.value, activeLabel.value, activeFacets.value);
return data;
@@ -231,14 +215,14 @@ export const useNotesStore = defineStore("notes", () => {
async function fetchOne(id: string): Promise<Note | null> {
try {
return await api.get<Note>(`/api/notes/${id}`);
return await repo.notes.get(id);
} catch {
return null;
}
}
async function createTitled(title: string): Promise<Note> {
const created = await api.post<Note>("/api/notes", { title, body: "" });
const created = await repo.notes.createTitled(title);
reconcile(created);
return created;
}
@@ -252,31 +236,30 @@ export const useNotesStore = defineStore("notes", () => {
if (n) n.position = total - index;
});
sortItems();
await api.post("/api/notes/reorder", { ids: orderedIds });
await repo.notes.reorder(orderedIds);
}
async function trash(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
reconcile(await repo.notes.trash(id));
useUiStore().showToast("Note moved to trash", { label: "Undo", run: () => void restore(id) });
}
async function restore(id: string): Promise<void> {
reconcile(await api.post<Note>(`/api/notes/${id}/restore`));
reconcile(await repo.notes.restore(id));
}
async function deleteForever(id: string): Promise<void> {
await api.del(`/api/notes/${id}`);
await repo.notes.deleteForever(id);
const idx = items.value.findIndex((n) => n.id === id);
if (idx >= 0) items.value.splice(idx, 1);
}
async function fetchRevisions(id: string): Promise<NoteRevision[]> {
const res = await api.get<{ revisions: NoteRevision[] }>(`/api/notes/${id}/revisions`);
return res.revisions;
return await repo.notes.revisions(id);
}
async function restoreRevision(id: string, revId: string): Promise<Note> {
const note = await api.post<Note>(`/api/notes/${id}/revisions/${revId}/restore`);
const note = await repo.notes.restoreRevision(id, revId);
reconcile(note);
return note;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import { repo } from "../adapters";
import router from "../router";
import { useUiStore } from "./ui";
import type { Note } from "./notes";
@@ -23,7 +23,7 @@ export const useReminderStore = defineStore("reminders", () => {
// Single owner of the reminders endpoint — the RemindersView reads its list through
// this too, so the URL + response shape live in one place.
async function fetchReminders(): Promise<Note[]> {
return (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes;
return repo.notes.reminders();
}
async function check(): Promise<void> {
+5 -5
View File
@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import { repo } from "../adapters";
import type { NoteFacets } from "./notes";
// A named, saved facet combination (a 'view'/lens). `params` mirrors NoteFacets.
@@ -17,7 +17,7 @@ export const useSavedFiltersStore = defineStore("savedFilters", () => {
async function load(): Promise<void> {
try {
items.value = (await api.get<{ filters: SavedFilter[] }>("/api/saved-filters")).filters;
items.value = await repo.savedFilters.list();
} catch {
// leave whatever we have
} finally {
@@ -26,18 +26,18 @@ export const useSavedFiltersStore = defineStore("savedFilters", () => {
}
async function create(name: string, params: NoteFacets): Promise<SavedFilter> {
const sf = await api.post<SavedFilter>("/api/saved-filters", { name, params });
const sf = await repo.savedFilters.create(name, params);
items.value.push(sf);
return sf;
}
async function remove(id: string): Promise<void> {
await api.del(`/api/saved-filters/${id}`);
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 api.patch<SavedFilter>(`/api/saved-filters/${id}`, { name });
const sf = await repo.savedFilters.rename(id, name);
const idx = items.value.findIndex((f) => f.id === id);
if (idx >= 0) items.value[idx] = sf;
}
+5 -9
View File
@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import { repo } from "../adapters";
export interface User {
id: string;
@@ -17,7 +17,7 @@ export const useSessionStore = defineStore("session", () => {
async function fetchMe(): Promise<void> {
try {
user.value = await api.get<User>("/api/auth/me");
user.value = await repo.auth.me();
} catch {
user.value = null;
} finally {
@@ -26,19 +26,15 @@ export const useSessionStore = defineStore("session", () => {
}
async function login(email: string, password: string): Promise<void> {
user.value = await api.post<User>("/api/auth/login", { email, password });
user.value = await repo.auth.login(email, password);
}
async function register(email: string, password: string, displayName: string): Promise<void> {
user.value = await api.post<User>("/api/auth/register", {
email,
password,
display_name: displayName,
});
user.value = await repo.auth.register(email, password, displayName);
}
async function logout(): Promise<void> {
await api.post("/api/auth/logout");
await repo.auth.logout();
user.value = null;
}
+2 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api } from "../api/client";
import { repo } from "../adapters";
export interface TitleEntry {
id: string;
@@ -14,8 +14,7 @@ export const useTitlesStore = defineStore("titles", () => {
async function load(): Promise<void> {
if (loaded.value) return;
const res = await api.get<{ titles: TitleEntry[] }>("/api/notes/titles");
items.value = res.titles;
items.value = await repo.notes.titles();
loaded.value = true;
}