Complete the export/import pair (task 1907). POST /api/notes/import takes
an uploaded .zip and appends its notes — never overwriting existing ones.
Two formats, auto-detected:
- ThoughtSync export: recognized by its notes.json (app == thoughtsync);
round-trips title/body/color/kind/pinned/archived/remind_at/timestamps/
labels/items and re-attaches image media from the zip.
- Google Keep Takeout: each Keep <note>.json → a note. Maps title,
textContent/listContent (+ checked), labels, Keep color enum (nearest
palette match), isPinned/isArchived, isTrashed (→ trash), created/edited
microsecond timestamps; folds annotation URLs into the body; resolves
attachment filePaths relative to the note's folder.
Imported notes reuse create_note's derivation + reconciliation:
display-title derive, #tag reconcile, [[wiki-link]] rewrite. Explicit
labels attach as manual (via_tag=false); inline #tags reconcile as tags.
Image attachments copied into media storage; non-image types (e.g. Keep
audio) skipped until any-file attachments land.
Frontend: an Import control in the sidebar (next to Export) — hidden file
input + FormData POST + result toast ("Imported N notes (M skipped)"),
reloading the board + labels. New upload icon; notes-store importNotes().
Tests: import auth-guard + pure-helper coverage (_usec_to_dt, _keep_spec
list/text/color/annotation/attachment mapping, _native_spec round-trip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
273 lines
8.4 KiB
TypeScript
273 lines
8.4 KiB
TypeScript
import { defineStore } from "pinia";
|
|
import { ref } from "vue";
|
|
import { api } from "../api/client";
|
|
import { useUiStore } from "./ui";
|
|
import type { NoteColor } from "../notes/colors";
|
|
|
|
export type NoteView = "active" | "archived" | "trash";
|
|
export type NoteKind = "text" | "list";
|
|
|
|
export interface NoteLabel {
|
|
id: string;
|
|
name: string;
|
|
color: string;
|
|
// True when this label is attached because of a #tag in the note body (kept in
|
|
// sync with the text); false = added manually via the picker.
|
|
via_tag: boolean;
|
|
}
|
|
|
|
export interface ChecklistItem {
|
|
id: string;
|
|
text: string;
|
|
checked: boolean;
|
|
position: number;
|
|
}
|
|
|
|
export interface Attachment {
|
|
id: string;
|
|
url: string;
|
|
mime: string;
|
|
}
|
|
|
|
// A past version of a note's title+body (version history).
|
|
export interface NoteRevision {
|
|
id: string;
|
|
title: string | null;
|
|
body: string;
|
|
created_at: string | null;
|
|
}
|
|
|
|
export interface Note {
|
|
id: string;
|
|
title: string | null;
|
|
// The note's display NAME: explicit title, else its first body line (server-derived).
|
|
// Every note has one, so body-only notes are still nameable + [[link]]-able.
|
|
display_title: string;
|
|
body: string;
|
|
color: NoteColor;
|
|
kind: NoteKind;
|
|
position: number;
|
|
pinned: boolean;
|
|
archived: boolean;
|
|
trashed: boolean;
|
|
remind_at: string | null;
|
|
labels: NoteLabel[];
|
|
items: ChecklistItem[];
|
|
attachments: Attachment[];
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
}
|
|
|
|
export const useNotesStore = defineStore("notes", () => {
|
|
const items = ref<Note[]>([]);
|
|
const loading = ref(false);
|
|
const view = ref<NoteView>("active");
|
|
const activeLabel = ref<string | null>(null);
|
|
|
|
function sortItems(): void {
|
|
items.value.sort((a, b) => {
|
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
|
if (a.position !== b.position) return b.position - a.position;
|
|
return (b.updated_at ?? "").localeCompare(a.updated_at ?? "");
|
|
});
|
|
}
|
|
|
|
function belongsHere(n: Note): boolean {
|
|
const v = view.value;
|
|
const inView =
|
|
v === "trash" ? n.trashed : v === "archived" ? !n.trashed && n.archived : !n.trashed && !n.archived;
|
|
if (!inView) return false;
|
|
if (activeLabel.value) return n.labels.some((lb) => lb.id === activeLabel.value);
|
|
return true;
|
|
}
|
|
|
|
function reconcile(note: Note): void {
|
|
const idx = items.value.findIndex((n) => n.id === note.id);
|
|
if (belongsHere(note)) {
|
|
if (idx >= 0) items.value[idx] = note;
|
|
else items.value.push(note);
|
|
sortItems();
|
|
} else if (idx >= 0) {
|
|
items.value.splice(idx, 1);
|
|
}
|
|
}
|
|
|
|
async function load(v: NoteView, labelId: string | null = null): Promise<void> {
|
|
view.value = v;
|
|
activeLabel.value = labelId;
|
|
loading.value = true;
|
|
try {
|
|
const query = labelId ? `/api/notes?filter=${v}&label=${labelId}` : `/api/notes?filter=${v}`;
|
|
const res = await api.get<{ notes: Note[] }>(query);
|
|
items.value = res.notes;
|
|
sortItems();
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function create(input: {
|
|
title: string;
|
|
body: string;
|
|
color: NoteColor;
|
|
kind?: NoteKind;
|
|
items?: string[];
|
|
}): Promise<Note> {
|
|
const note = await api.post<Note>("/api/notes", input);
|
|
reconcile(note);
|
|
return note;
|
|
}
|
|
|
|
async function mutate(
|
|
id: string,
|
|
changes: Partial<Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at">>,
|
|
): Promise<void> {
|
|
reconcile(await api.patch<Note>(`/api/notes/${id}`, changes));
|
|
}
|
|
|
|
const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned });
|
|
const setArchived = async (id: string, archived: boolean): Promise<void> => {
|
|
await mutate(id, { archived });
|
|
if (archived)
|
|
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
|
|
};
|
|
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
|
|
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
|
|
const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
|
|
const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
|
|
|
|
async function setLabels(id: string, labelIds: string[]): Promise<void> {
|
|
reconcile(await api.put<Note>(`/api/notes/${id}/labels`, { label_ids: labelIds }));
|
|
}
|
|
|
|
async function addItem(id: string, text: string): Promise<void> {
|
|
reconcile(await api.post<Note>(`/api/notes/${id}/items`, { 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));
|
|
}
|
|
|
|
async function deleteItem(id: string, itemId: string): Promise<void> {
|
|
reconcile(await api.del<Note>(`/api/notes/${id}/items/${itemId}`));
|
|
}
|
|
|
|
async function uploadAttachment(id: string, file: File): Promise<void> {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
const resp = await fetch(`/api/notes/${id}/attachments`, { method: "POST", credentials: "include", body: form });
|
|
const data: unknown = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
const message =
|
|
typeof data === "object" && data !== null && "error" in data
|
|
? String((data as { error: unknown }).error)
|
|
: "Upload failed.";
|
|
throw { error: message, status: resp.status };
|
|
}
|
|
reconcile(data as Note);
|
|
}
|
|
|
|
async function deleteAttachment(id: string, attId: string): Promise<void> {
|
|
reconcile(await api.del<Note>(`/api/notes/${id}/attachments/${attId}`));
|
|
}
|
|
|
|
async function importNotes(file: File): Promise<{ source: string; imported: number; skipped: number }> {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
const resp = await fetch("/api/notes/import", { method: "POST", credentials: "include", body: form });
|
|
const data: unknown = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
const message =
|
|
typeof data === "object" && data !== null && "error" in data
|
|
? String((data as { error: unknown }).error)
|
|
: "Import failed.";
|
|
throw { error: message, status: resp.status };
|
|
}
|
|
// Refresh the current lens so imported notes appear (labels reloaded by caller).
|
|
await load(view.value, activeLabel.value);
|
|
return data as { source: string; imported: number; skipped: number };
|
|
}
|
|
|
|
async function fetchOne(id: string): Promise<Note | null> {
|
|
try {
|
|
return await api.get<Note>(`/api/notes/${id}`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function createTitled(title: string): Promise<Note> {
|
|
const created = await api.post<Note>("/api/notes", { title, body: "" });
|
|
reconcile(created);
|
|
return created;
|
|
}
|
|
|
|
async function reorder(orderedIds: string[]): Promise<void> {
|
|
// Optimistically assign positions matching the backend (total - index), sort,
|
|
// then persist.
|
|
const total = orderedIds.length;
|
|
orderedIds.forEach((id, index) => {
|
|
const n = items.value.find((x) => x.id === id);
|
|
if (n) n.position = total - index;
|
|
});
|
|
sortItems();
|
|
await api.post("/api/notes/reorder", { ids: orderedIds });
|
|
}
|
|
|
|
async function trash(id: string): Promise<void> {
|
|
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
|
|
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`));
|
|
}
|
|
|
|
async function deleteForever(id: string): Promise<void> {
|
|
await api.del(`/api/notes/${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;
|
|
}
|
|
|
|
async function restoreRevision(id: string, revId: string): Promise<Note> {
|
|
const note = await api.post<Note>(`/api/notes/${id}/revisions/${revId}/restore`);
|
|
reconcile(note);
|
|
return note;
|
|
}
|
|
|
|
return {
|
|
items,
|
|
loading,
|
|
view,
|
|
activeLabel,
|
|
load,
|
|
create,
|
|
setPinned,
|
|
setArchived,
|
|
setColor,
|
|
setKind,
|
|
setReminder,
|
|
saveEdit,
|
|
setLabels,
|
|
addItem,
|
|
updateItem,
|
|
deleteItem,
|
|
uploadAttachment,
|
|
deleteAttachment,
|
|
importNotes,
|
|
fetchOne,
|
|
createTitled,
|
|
reorder,
|
|
trash,
|
|
restore,
|
|
deleteForever,
|
|
fetchRevisions,
|
|
restoreRevision,
|
|
};
|
|
});
|