CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of this field: a card is one neutral surface per theme, and the only coloured thing on a board is a tag. What was left was a column written by a picker and read by nothing. Rule 22 — the old path comes out completely. No flag, no fallback, no "override if set". Server: the column, the `?color=` facet, the create/update/serialise paths, the sync assignment, the front-matter line, and Keep's colour map. Alembic 0029 drops it and sweeps `"color"` out of stored saved-filter params — a view that silently filtered on a field the app no longer has would return nothing and never say why. That sweep is Python, not `params::jsonb - 'color'`, because Postgres has no try-cast and one malformed blob would abort a migration that is running over somebody's saved views. `NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on the model that lost one is an invitation to put the column back; labels still name a colour, so the vocabulary belongs where the normalizer already is. Core: the field, the facet, the `NoteCreateInput`, and every read and write in store/push/pull. Local schema v9 drops the column and does the same saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and `NoteDraft.color` with it. Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule, the FilterBar colour row, the facet in the query round-trip, and the colour half of the editor's baseline-and-save. Android: the `ColorSheet`, the `Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`. ## The protocol: v4, and the floor deliberately stays at 3 Checked against `compat.rs` and the push handler rather than trusting the `#[serde(default)]` annotation, because the v2 precedent points the other way: v2 dropped `kind` and `title` and DID raise both floors, on the rule that dropping a field a client sends and expects back is breaking. `color` fails the second half of that test. A v3 client reading a v4 note gets `"default"` from its own serde default and draws the colour it derives locally — the board it drew yesterday. A v3 client pushing `color` has the key ignored, since `_assign_note_fields` reads its payload key by key and never validates the shape. Neither direction errors and neither shows anything wrong. `title` was the note's NAME; this is a field that no longer renders. So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both floors stay at 3. `docs/sync.md` carries the reasoning and the per-version history, and its push example is brought back in line — it still listed `title`, `kind` and `items`, all gone before this. Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:` imports fine, the key simply read past. Old exports must still import. #3041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
110 lines
5.4 KiB
TypeScript
110 lines
5.4 KiB
TypeScript
// REST implementation of the repository seam: maps each semantic operation to the
|
|
// existing HTTP endpoint via `api/client`. This is the ONLY place that knows about
|
|
// URL paths, query strings, and multipart bodies. Behaviour here must stay a
|
|
// verbatim mirror of the calls the stores/views made before the seam existed — the
|
|
// web app is unchanged; the offline `local.ts` source (M10.5) is the alternative.
|
|
|
|
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";
|
|
import type { PublicConfig } from "../stores/config";
|
|
import type {
|
|
DeviceToken,
|
|
ImportResult,
|
|
NoteChanges,
|
|
NoteCreateInput,
|
|
NoteListQuery,
|
|
ChecklistItemChanges,
|
|
Repo,
|
|
} from "./repo";
|
|
|
|
// Render a board query to the GET /api/notes query string. Mirrors the param
|
|
// building that used to live in notes.store.load()/TimelineView (order-preserving;
|
|
// query-param order is irrelevant to the server, but kept close for review).
|
|
function notesQuery(q: NoteListQuery): string {
|
|
const params = new URLSearchParams();
|
|
params.set("filter", q.view);
|
|
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);
|
|
if (q.sort) params.set("sort", q.sort);
|
|
return params.toString();
|
|
}
|
|
|
|
function fileForm(file: File): FormData {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
return form;
|
|
}
|
|
|
|
export const rest: Repo = {
|
|
config: {
|
|
get: () => api.get<PublicConfig>("/api/config"),
|
|
},
|
|
|
|
auth: {
|
|
me: () => api.get<User>("/api/auth/me"),
|
|
login: (email, password) => api.post<User>("/api/auth/login", { email, password }),
|
|
register: (email, password, displayName) =>
|
|
api.post<User>("/api/auth/register", { email, password, display_name: displayName }),
|
|
logout: () => api.post<void>("/api/auth/logout"),
|
|
},
|
|
|
|
devices: {
|
|
list: async () => (await api.get<{ devices: Device[] }>("/api/auth/devices")).devices,
|
|
create: (name) => api.post<DeviceToken>("/api/auth/devices", { name }),
|
|
remove: (id) => api.del<void>(`/api/auth/devices/${id}`),
|
|
},
|
|
|
|
labels: {
|
|
list: async () => (await api.get<{ labels: Label[] }>("/api/labels")).labels,
|
|
create: (name) => api.post<Label>("/api/labels", { name }),
|
|
rename: (id, name) => api.patch<Label>(`/api/labels/${id}`, { name }),
|
|
setColor: (id, color) => api.patch<Label>(`/api/labels/${id}`, { color }),
|
|
remove: (id) => api.del<void>(`/api/labels/${id}`),
|
|
merge: (sourceId, into) => api.post<Label>(`/api/labels/${sourceId}/merge`, { into }),
|
|
},
|
|
|
|
notes: {
|
|
list: async (query) => (await api.get<{ notes: Note[] }>(`/api/notes?${notesQuery(query)}`)).notes,
|
|
get: (id) => api.get<Note>(`/api/notes/${id}`),
|
|
create: (input: NoteCreateInput) => api.post<Note>("/api/notes", input),
|
|
update: (id, changes: NoteChanges) => api.patch<Note>(`/api/notes/${id}`, changes),
|
|
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
|
|
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
|
|
setLabels: (id, labelIds) => api.put<Note>(`/api/notes/${id}/labels`, { label_ids: labelIds }),
|
|
addItem: (id, text) => api.post<Note>(`/api/notes/${id}/items`, { text }),
|
|
updateItem: (id, itemId, changes: ChecklistItemChanges) =>
|
|
api.patch<Note>(`/api/notes/${id}/items/${itemId}`, changes),
|
|
deleteItem: (id, itemId) => api.del<Note>(`/api/notes/${id}/items/${itemId}`),
|
|
uploadAttachment: (id, file) => api.postForm<Note>(`/api/notes/${id}/attachments`, fileForm(file)),
|
|
deleteAttachment: (id, attId) => api.del<Note>(`/api/notes/${id}/attachments/${attId}`),
|
|
unfurl: (id, url) => api.post<Note>(`/api/notes/${id}/unfurl`, { url }),
|
|
deletePreview: (id, previewId) => api.del<Note>(`/api/notes/${id}/previews/${previewId}`),
|
|
import: (file) => api.postForm<ImportResult>("/api/notes/import", fileForm(file)),
|
|
reorder: (orderedIds) => api.post<void>("/api/notes/reorder", { ids: orderedIds }),
|
|
trash: (id) => api.post<Note>(`/api/notes/${id}/trash`),
|
|
restore: (id) => api.post<Note>(`/api/notes/${id}/restore`),
|
|
deleteForever: (id) => api.del<void>(`/api/notes/${id}`),
|
|
revisions: async (id) => (await api.get<{ revisions: NoteRevision[] }>(`/api/notes/${id}/revisions`)).revisions,
|
|
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,
|
|
},
|
|
|
|
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 }),
|
|
},
|
|
};
|