feat(notes): billeder og bilag følger med ind — og ud
Release / Build and publish image (push) Failing after 9m58s
Release / Build and publish image (push) Failing after 9m58s
Du spurgte, hvad der sker med bilag og indsatte billeder. Svaret var: de forsvandt stille. Scriptet konverterede til ren tekst, og en note fra Apple Noter, der kommer over som ord alene, er ikke den note — den er et referat af den. En note kan nu bære filer, i samme tabel som opgaver og grupper allerede bruger. Filerne er indholdsadresserede og deduplikeres på disken, upload-vejen er skrevet og afprøvet, og oprydningen af forældreløse filer går allerede den tabel igennem. En tabel mere ville være et alt-sammen mere. Importen læser arkivet i to omgange. Alt, der ikke er Markdown, gemmes først, så en note, der bliver læst, allerede har adresser at få sine links rettet til — noter og billeder ligger i den rækkefølge, arkivet nu har dem, og ét gennemløb ville efterlade halvdelen af henvisningerne pegende på ingenting. Kun de filer, noten faktisk nævner. Et arkiv er én mappe til mange noter, og at hænge hvert billede på hver note ville gøre hundrede noter til ti tusind bilag. Hentningen af en note-fil gav 404, fordi adgangskontrollen ikke kendte noter som forælder. Et billede midt i den tekst, der navngav det, svarede altså "findes ikke". Den følger nu noten, som følger sit projekt — samme kæde som alt andet. AppleScriptet gemmer nu hver vedhæftning og skriver en henvisning til den i noten. Vedhæftninger uden fil bag sig — links til websider, kort — springes over frem for at vælte kørslen. Migration 0021. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
11fdbefe1b
commit
414a35eaba
@@ -327,6 +327,30 @@ func (s *Server) reachAttachment(r *http.Request, attachmentID string) (canEdit
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// A file inside a note follows the note, which follows its project — the same
|
||||
// chain everything else here follows. Without this a picture imported with a
|
||||
// note answered 404 while sitting in the middle of the text that named it.
|
||||
if a.NoteID != "" {
|
||||
n, err := s.db.Note(r.Context(), a.NoteID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if n == nil {
|
||||
return false, store.ErrNotFound
|
||||
}
|
||||
if n.ProjectID == "" {
|
||||
if n.CreatedBy != user.ID {
|
||||
return false, store.ErrNotFound
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
role, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, store.RoleViewer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return role.CanEdit(), nil
|
||||
}
|
||||
|
||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -284,3 +286,174 @@ func noteFilename(title string) string {
|
||||
}
|
||||
return strings.TrimRight(name, " .")
|
||||
}
|
||||
|
||||
// maxNoteImportBytes bounds one upload. Generous for text — a decade of notes is
|
||||
// a few megabytes — and small enough that a wrong file is refused rather than read.
|
||||
const maxNoteImportBytes = 64 << 20
|
||||
|
||||
// handleImportNotes reads a zip of Markdown files and makes a note of each.
|
||||
//
|
||||
// The mirror of the export, and deliberately the same shape: whatever comes out of
|
||||
// this program goes back into it. It is also the way in from anywhere else, since
|
||||
// a folder of Markdown is what Obsidian, Bear, iA Writer and a shell script all
|
||||
// produce — and what the Apple Notes exporter in tools/ writes.
|
||||
//
|
||||
// Filenames are ignored. The title is the first line of the body, everywhere else
|
||||
// in this program, and making it the filename here would be a second rule for the
|
||||
// same thing that disagrees the moment somebody renames a file.
|
||||
func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxNoteImportBytes)
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, "the file is too large")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zr, err := zip.NewReader(file.(io.ReaderAt), header.Size)
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "not a zip archive"})
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
created, skipped, files := 0, 0, 0
|
||||
|
||||
// Two passes. Everything that is not Markdown is stored first, so that when a
|
||||
// note is read its pictures already have addresses to be rewritten to — a note
|
||||
// and its images arrive in whatever order the archive happens to hold them, and
|
||||
// one pass would leave half the links pointing at nothing.
|
||||
stored := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hashed, size, err := s.storeUpload(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
s.log.Warn("import note file", "err", err, "file", name)
|
||||
continue
|
||||
}
|
||||
stored[name] = hashed
|
||||
_ = size
|
||||
files++
|
||||
}
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || skipFile(name) || !strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(rc, 2<<20))
|
||||
rc.Close()
|
||||
if err != nil || strings.TrimSpace(string(body)) == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
n := &store.Note{CreatedBy: user.ID, Body: string(body)}
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.log.Warn("import note", "err", err, "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// The pictures the note referred to, now that it has an id to hang them on.
|
||||
// The text is rewritten to the addresses they were given, so an image that
|
||||
// sat in the middle of a paragraph is still in the middle of it.
|
||||
if rewritten, attached := s.attachToNote(r, n, path.Dir(name), stored, zr); attached > 0 {
|
||||
n.Body = rewritten
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.log.Warn("rewrite note links", "err", err, "note", n.ID)
|
||||
}
|
||||
}
|
||||
created++
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"created": created, "skipped": skipped, "files": files,
|
||||
})
|
||||
}
|
||||
|
||||
// skipFile is the things in an archive that are not content: directories' own
|
||||
// entries, macOS resource forks, and anything trying to climb out of the archive.
|
||||
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
|
||||
// would double a whole library with each copy full of binary rubbish.
|
||||
func skipFile(name string) bool {
|
||||
return strings.HasPrefix(name, "__MACOSX/") ||
|
||||
strings.HasPrefix(path.Base(name), "._") ||
|
||||
strings.HasPrefix(path.Base(name), ".") ||
|
||||
strings.Contains(name, "..")
|
||||
}
|
||||
|
||||
// attachToNote records the files a note's text points at and rewrites the text to
|
||||
// where they now live. Returns the new body and how many were attached.
|
||||
//
|
||||
// Only the files this note refers to. An archive is one folder for many notes, and
|
||||
// hanging every picture on every note would turn a library of a hundred into ten
|
||||
// thousand attachments.
|
||||
func (s *Server) attachToNote(
|
||||
r *http.Request, n *store.Note, dir string, stored map[string]string, zr *zip.Reader,
|
||||
) (string, int) {
|
||||
body := n.Body
|
||||
attached := 0
|
||||
|
||||
for _, f := range zr.File {
|
||||
hashed, ok := stored[f.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
base := path.Base(f.Name)
|
||||
// The two ways a Markdown file names a neighbouring image: by the path it
|
||||
// was written with, and by its bare name.
|
||||
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
||||
if !strings.Contains(body, base) && !strings.Contains(body, relative) {
|
||||
continue
|
||||
}
|
||||
|
||||
a := &store.Attachment{
|
||||
NoteID: n.ID,
|
||||
Filename: base,
|
||||
MimeType: mimeOf(base),
|
||||
Size: int64(f.UncompressedSize64),
|
||||
Path: hashed,
|
||||
UploadedBy: n.CreatedBy,
|
||||
}
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
s.log.Warn("attach to note", "err", err, "file", base)
|
||||
continue
|
||||
}
|
||||
|
||||
url := "/api/v1/attachments/" + a.ID
|
||||
body = strings.ReplaceAll(body, relative, url)
|
||||
body = strings.ReplaceAll(body, base, url)
|
||||
attached++
|
||||
}
|
||||
return body, attached
|
||||
}
|
||||
|
||||
// mimeOf guesses from the extension. The archive does not carry a type, and
|
||||
// sniffing the bytes would mean reading every file twice.
|
||||
func mimeOf(name string) string {
|
||||
if t := mime.TypeByExtension(path.Ext(name)); t != "" {
|
||||
return t
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package httpapi
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -198,3 +201,166 @@ func keysOf(m map[string]string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Out and back in again. If an export cannot be imported, it is not an export —
|
||||
// it is a file somebody hopes will be readable later.
|
||||
func TestNotesSurviveTheRoundTripThroughAZip(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var archive bytes.Buffer
|
||||
zw := zip.NewWriter(&archive)
|
||||
files := map[string]string{
|
||||
"Møde med Anders.md": "# Møde med Anders\n\nHan vil have **kaffe** hver uge. #Firma",
|
||||
"noget andet.md": "Løse tanker\n\nog en linje til",
|
||||
// A zip made on a Mac carries these beside every file. Importing them would
|
||||
// double the whole library, with each copy full of binary rubbish.
|
||||
"__MACOSX/._Møde med Anders.md": "\x00\x05\x16\x07binært",
|
||||
"billede.png": "ikke en note",
|
||||
"tom.md": " \n ",
|
||||
}
|
||||
for name, body := range files {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(f, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var form bytes.Buffer
|
||||
mw := multipart.NewWriter(&form)
|
||||
part, _ := mw.CreateFormFile("file", "noter.zip")
|
||||
part.Write(archive.Bytes())
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("import: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct{ Created, Skipped int }
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
if out.Created != 2 {
|
||||
t.Fatalf("imported %d notes, want the 2 real ones", out.Created)
|
||||
}
|
||||
|
||||
// The title came from the first line, not from the filename — which is the same
|
||||
// rule as everywhere else, and the reason renaming a file cannot rename a note.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes", nil)
|
||||
titles := map[string]bool{}
|
||||
for _, raw := range listed["notes"].([]any) {
|
||||
titles[raw.(map[string]any)["title"].(string)] = true
|
||||
}
|
||||
if !titles["Møde med Anders"] || !titles["Løse tanker"] {
|
||||
t.Errorf("the titles came out as %v", titles)
|
||||
}
|
||||
|
||||
// And the #tag was read on the way in, so an imported note lands on its project
|
||||
// exactly as a typed one does.
|
||||
_, linked := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
||||
if len(linked["notes"].([]any)) != 1 {
|
||||
t.Errorf("the imported note did not link to its project")
|
||||
}
|
||||
}
|
||||
|
||||
// A note from Apple Notes is full of photographs. One that arrives as words alone
|
||||
// is not that note — it is a summary of it.
|
||||
func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var archive bytes.Buffer
|
||||
zw := zip.NewWriter(&archive)
|
||||
write := func(name, body string) {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(f, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("noter/Kvittering.md", "# Kvittering\n\nHer er den:\n\n\n\nog resten af teksten")
|
||||
write("noter/vedhaeftninger/bon.png", "\x89PNG\r\n\x1a\nlad som om")
|
||||
// Another note in the same archive, which must not inherit the first one's
|
||||
// picture: an archive is one folder for many notes.
|
||||
write("noter/Uden billede.md", "Uden billede\n\nbare tekst")
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var form bytes.Buffer
|
||||
mw := multipart.NewWriter(&form)
|
||||
part, _ := mw.CreateFormFile("file", "noter.zip")
|
||||
part.Write(archive.Bytes())
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out struct{ Created, Skipped, Files int }
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
if out.Created != 2 || out.Files != 1 {
|
||||
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||
notes := listed["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("found %d notes", len(notes))
|
||||
}
|
||||
body := notes[0].(map[string]any)["body"].(string)
|
||||
|
||||
// The link points at the file where it now lives, not at a path inside an
|
||||
// archive nobody kept.
|
||||
if strings.Contains(body, "vedhaeftninger/bon.png") {
|
||||
t.Errorf("the link still points into the archive: %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "/api/v1/attachments/") {
|
||||
t.Errorf("the picture was not attached: %q", body)
|
||||
}
|
||||
// And it is still where it was written, in the middle of the note.
|
||||
if !strings.HasPrefix(body, "# Kvittering") || !strings.Contains(body, "og resten af teksten") {
|
||||
t.Errorf("the note was rearranged: %q", body)
|
||||
}
|
||||
|
||||
// The picture is really there and really readable.
|
||||
id := body[strings.Index(body, "/api/v1/attachments/")+len("/api/v1/attachments/"):]
|
||||
id = strings.SplitN(id, ")", 2)[0]
|
||||
got, _ := ts.do(t, "GET", "/api/v1/attachments/"+id, nil)
|
||||
if got.StatusCode != http.StatusOK {
|
||||
t.Errorf("the attachment answers %d", got.StatusCode)
|
||||
}
|
||||
|
||||
// The other note did not inherit it.
|
||||
_, other := ts.do(t, "GET", "/api/v1/notes?q=Uden", nil)
|
||||
for _, raw := range other["notes"].([]any) {
|
||||
if strings.Contains(raw.(map[string]any)["body"].(string), "attachments/") {
|
||||
t.Error("a note that mentioned no picture was given one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +283,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Route("/notes", func(r chi.Router) {
|
||||
r.Get("/", s.handleListNotes)
|
||||
r.Post("/", s.handleCreateNote)
|
||||
r.Post("/import", s.handleImportNotes)
|
||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
|
||||
Reference in New Issue
Block a user