Import: ThoughtSync-native round-trip + Google Keep Takeout
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
This commit is contained in:
@@ -7,6 +7,7 @@ import { useLabelsStore } from "../stores/labels";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import CommandPalette from "./CommandPalette.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import ImportNotes from "./ImportNotes.vue";
|
||||
import LabelsModal from "./LabelsModal.vue";
|
||||
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||
|
||||
@@ -309,6 +310,7 @@ async function signOut() {
|
||||
<a href="/api/notes/export" download class="nav-link" title="Download all your notes as a zip">
|
||||
<Icon name="download" /> Export
|
||||
</a>
|
||||
<ImportNotes />
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const paths: Record<string, string> = {
|
||||
merge: '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
|
||||
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { useLabelsStore } from "../stores/labels";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
// Sidebar counterpart to Export: pick a .zip (a ThoughtSync export for round-trip,
|
||||
// or a Google Keep Takeout archive) and import its notes. Additive — never
|
||||
// overwrites existing notes.
|
||||
const notes = useNotesStore();
|
||||
const labels = useLabelsStore();
|
||||
const ui = useUiStore();
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const busy = ref(false);
|
||||
|
||||
function pick() {
|
||||
if (!busy.value) inputRef.value?.click();
|
||||
}
|
||||
|
||||
async function onFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = ""; // reset so picking the same file again re-fires change
|
||||
if (!file) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
const res = await notes.importNotes(file);
|
||||
await labels.load(); // surface any labels the import created
|
||||
const noun = res.imported === 1 ? "note" : "notes";
|
||||
const tail = res.skipped ? ` (${res.skipped} skipped)` : "";
|
||||
ui.showToast(`Imported ${res.imported} ${noun}${tail}.`);
|
||||
} catch (err) {
|
||||
ui.showToast((err as { error?: string }).error ?? "Import failed.");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link w-full disabled:opacity-60"
|
||||
:disabled="busy"
|
||||
title="Import a ThoughtSync export or a Google Keep Takeout zip"
|
||||
@click="pick"
|
||||
>
|
||||
<Icon name="upload" /> {{ busy ? "Importing…" : "Import" }}
|
||||
</button>
|
||||
<input ref="inputRef" type="file" accept=".zip,application/zip" class="hidden" @change="onFile" />
|
||||
</template>
|
||||
@@ -171,6 +171,23 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
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}`);
|
||||
@@ -242,6 +259,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
deleteItem,
|
||||
uploadAttachment,
|
||||
deleteAttachment,
|
||||
importNotes,
|
||||
fetchOne,
|
||||
createTitled,
|
||||
reorder,
|
||||
|
||||
Reference in New Issue
Block a user