Files
inkwell/frontend/src/components/ImportNotes.vue
T
bvandeusenandClaude Opus 4.8 333ab9ce74
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 33s
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
2026-07-22 21:24:02 -04:00

53 lines
1.7 KiB
Vue

<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>