Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ffbd9533b | ||
|
|
f6c6bdccbb |
@@ -394,12 +394,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
// Everything is served as a download rather than rendered. An uploaded SVG or
|
// Almost everything is served as a download rather than rendered. An uploaded
|
||||||
// HTML file displayed inline would run its own script on this origin, with the
|
// SVG or HTML file displayed inline would run its own script on this origin,
|
||||||
// session cookie attached — attachments are the one place a user supplies
|
// with the session cookie attached — attachments are the one place a user
|
||||||
// content that another user opens.
|
// supplies content that another user opens.
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
//
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
|
// The exception is a short list of raster images, and it is not a softening of
|
||||||
|
// that rule but what makes a note work: a note carries its pictures in the text
|
||||||
|
// as ``, and served as octet-stream every one of them is a broken image.
|
||||||
|
// The whole Apple Notes import exists to bring those pictures along.
|
||||||
|
//
|
||||||
|
// Safe because it is an allowlist of formats that cannot carry script, sent as
|
||||||
|
// the exact type with `nosniff` — so the browser must treat it as that image or
|
||||||
|
// not at all, and can never be talked into reading it as HTML. SVG is
|
||||||
|
// deliberately absent: it is a document, not a picture, and it is the one image
|
||||||
|
// type that can run script.
|
||||||
|
disposition := "attachment"
|
||||||
|
contentType := "application/octet-stream"
|
||||||
|
if inlineImage[a.MimeType] {
|
||||||
|
disposition = "inline"
|
||||||
|
contentType = a.MimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+
|
||||||
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
||||||
@@ -408,6 +426,24 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
|||||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inlineImage is what may be shown rather than downloaded.
|
||||||
|
//
|
||||||
|
// Raster formats only, and named one by one rather than matched on an "image/"
|
||||||
|
// prefix: the prefix would let image/svg+xml through, which is a document that
|
||||||
|
// can run script, and a list that grows by accident is how that happens.
|
||||||
|
var inlineImage = map[string]bool{
|
||||||
|
"image/png": true,
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/gif": true,
|
||||||
|
"image/webp": true,
|
||||||
|
"image/avif": true,
|
||||||
|
// Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig
|
||||||
|
// som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem,
|
||||||
|
// virker de, uden at nogen skal huske hvorfor de ikke gjorde.
|
||||||
|
"image/heic": true,
|
||||||
|
"image/heif": true,
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
attachmentID := chi.URLParam(r, "attachmentID")
|
attachmentID := chi.URLParam(r, "attachmentID")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -421,10 +422,20 @@ func (s *Server) attachToNote(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
base := path.Base(f.Name)
|
base := path.Base(f.Name)
|
||||||
// The two ways a Markdown file names a neighbouring image: by the path it
|
// The three ways a Markdown file names a neighbouring image: by its path
|
||||||
// was written with, and by its bare name.
|
// inside the archive, by the path relative to the note, and by its bare name.
|
||||||
|
//
|
||||||
|
// The archive path was missing, and it is the one an Apple Notes export
|
||||||
|
// actually writes — `vedhaeftninger/751-2-IMG_0615.heic`, with the note at the
|
||||||
|
// root beside the folder. Only the bare name matched, so only the bare name
|
||||||
|
// was replaced, and every picture in every imported note ended up pointing at
|
||||||
|
// `vedhaeftninger//api/v1/attachments/…`, which is nothing. The images were
|
||||||
|
// stored and attached correctly; it was the link that was wrong.
|
||||||
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
||||||
if !strings.Contains(body, base) && !strings.Contains(body, relative) {
|
refs := []string{f.Name, relative, base}
|
||||||
|
if !strings.Contains(body, f.Name) &&
|
||||||
|
!strings.Contains(body, relative) &&
|
||||||
|
!strings.Contains(body, base) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,9 +452,15 @@ func (s *Server) attachToNote(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Længste først. Erstattes det bare navn inde i en længere sti, bliver mappen
|
||||||
|
// stående foran adressen, og linket peger på ingenting.
|
||||||
url := "/api/v1/attachments/" + a.ID
|
url := "/api/v1/attachments/" + a.ID
|
||||||
body = strings.ReplaceAll(body, relative, url)
|
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
|
||||||
body = strings.ReplaceAll(body, base, url)
|
for _, ref := range refs {
|
||||||
|
if ref != "" {
|
||||||
|
body = strings.ReplaceAll(body, ref, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
attached++
|
attached++
|
||||||
}
|
}
|
||||||
return body, attached
|
return body, attached
|
||||||
|
|||||||
@@ -255,11 +255,36 @@ def main() -> int:
|
|||||||
# De tomme linjer skal forbi først: <h1> ligger inde i en <div>, hvis
|
# De tomme linjer skal forbi først: <h1> ligger inde i en <div>, hvis
|
||||||
# starttag tømmer linjen før den — så første linje er tom, og
|
# starttag tømmer linjen før den — så første linje er tom, og
|
||||||
# sammenligningen så på ingenting.
|
# sammenligningen så på ingenting.
|
||||||
|
# Titlen kan være delt over flere overskrifter.
|
||||||
|
#
|
||||||
|
# Noter kalder noten det, der står på de første linjer, sat sammen med
|
||||||
|
# mellemrum — så en note, hvis to første linjer er "CPU" og "CAN", hedder
|
||||||
|
# "CPU CAN". Sammenlignede man kun med den første linje, gik det fri, og
|
||||||
|
# titlen stod bagefter to gange: én gang som overskrift og én gang som de
|
||||||
|
# stumper, den var lavet af. Fjorten noter ud af tolv hundrede.
|
||||||
|
#
|
||||||
|
# Overskrifterne øverst tages derfor med, så længe de tilsammen stadig er
|
||||||
|
# begyndelsen på titlen, og fjernes kun, hvis de til sidst er hele titlen.
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
while lines and not lines[0].strip():
|
while lines and not lines[0].strip():
|
||||||
lines.pop(0)
|
lines.pop(0)
|
||||||
if lines and lines[0].lstrip("# ").strip() == title.strip():
|
|
||||||
lines = lines[1:]
|
taken, joined = 0, ""
|
||||||
|
for line in lines:
|
||||||
|
if not line.strip():
|
||||||
|
taken += 1
|
||||||
|
continue
|
||||||
|
if not line.lstrip().startswith("#"):
|
||||||
|
break
|
||||||
|
candidate = (joined + " " + line.lstrip("# ").strip()).strip()
|
||||||
|
if not title.strip().startswith(candidate):
|
||||||
|
break
|
||||||
|
joined, taken = candidate, taken + 1
|
||||||
|
if joined == title.strip():
|
||||||
|
break
|
||||||
|
|
||||||
|
if joined == title.strip():
|
||||||
|
lines = lines[taken:]
|
||||||
while lines and not lines[0].strip():
|
while lines and not lines[0].strip():
|
||||||
lines.pop(0)
|
lines.pop(0)
|
||||||
text = "\n".join(lines)
|
text = "\n".join(lines)
|
||||||
|
|||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pakker en Apple Noter-eksport i portioner, der kan importeres.
|
||||||
|
|
||||||
|
Eksporten af tolv hundrede noter fylder knap to hundrede megabyte, og næsten
|
||||||
|
alt sammen er billeder: teksten selv er under fire. Importen tager 64 MB ad
|
||||||
|
gangen, og tunnelen foran den tager mindre — så det skal deles.
|
||||||
|
|
||||||
|
Delt efter hvad en note *vejer med sine billeder*, ikke efter antal. De fleste
|
||||||
|
noter har ingen, så de fylder én portion til sammen; de få med et fotoalbum i
|
||||||
|
får hver deres. En note og dens billeder ligger altid i samme portion, for
|
||||||
|
linkene i teksten er relative, og en note, hvis billeder kom med i næste
|
||||||
|
portion, ville pege på ingenting.
|
||||||
|
|
||||||
|
./pak-noter-i-portioner.py ~/Desktop/apple-noter [MB]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
# Under importens 64 MB og under det, en tunnel plejer at tage. Hellere en
|
||||||
|
# portion mere end en, der ryger på målstregen.
|
||||||
|
DEFAULT_LIMIT_MB = 40
|
||||||
|
|
||||||
|
LINK = re.compile(r"!\[[^\]]*\]\((vedhaeftninger/[^)]+)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
src = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/Desktop/apple-noter")
|
||||||
|
limit = int(float(sys.argv[2] if len(sys.argv) > 2 else DEFAULT_LIMIT_MB) * 1024 * 1024)
|
||||||
|
out_dir = src.rstrip("/") + "-portioner"
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
|
notes = sorted(f for f in os.listdir(src) if f.endswith(".md"))
|
||||||
|
if not notes:
|
||||||
|
print(f"Ingen .md-filer i {src}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Hvad hver note vejer, og hvad den trækker med sig.
|
||||||
|
weighed = []
|
||||||
|
for name in notes:
|
||||||
|
path = os.path.join(src, name)
|
||||||
|
text = open(path, encoding="utf-8", errors="replace").read()
|
||||||
|
files = []
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
for rel in LINK.findall(text):
|
||||||
|
f = os.path.join(src, rel)
|
||||||
|
if os.path.exists(f):
|
||||||
|
files.append(rel)
|
||||||
|
size += os.path.getsize(f)
|
||||||
|
weighed.append((name, files, size))
|
||||||
|
|
||||||
|
# En note, der alene er større end grænsen, får sin egen portion frem for at
|
||||||
|
# blive sprunget over: den skal med, og en portion på 25 MB er stadig en, der
|
||||||
|
# kan sendes.
|
||||||
|
batches, current, current_size = [], [], 0
|
||||||
|
for item in weighed:
|
||||||
|
if current and current_size + item[2] > limit:
|
||||||
|
batches.append(current)
|
||||||
|
current, current_size = [], 0
|
||||||
|
current.append(item)
|
||||||
|
current_size += item[2]
|
||||||
|
if current:
|
||||||
|
batches.append(current)
|
||||||
|
|
||||||
|
print(f"{len(notes)} noter → {len(batches)} portioner\n")
|
||||||
|
for i, batch in enumerate(batches, start=1):
|
||||||
|
zip_path = os.path.join(out_dir, f"noter-{i:02d}.zip")
|
||||||
|
seen = set()
|
||||||
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for name, files, _ in batch:
|
||||||
|
z.write(os.path.join(src, name), name)
|
||||||
|
for rel in files:
|
||||||
|
if rel in seen:
|
||||||
|
continue
|
||||||
|
seen.add(rel)
|
||||||
|
z.write(os.path.join(src, rel), rel)
|
||||||
|
mb = os.path.getsize(zip_path) / 1024 / 1024
|
||||||
|
print(f" {os.path.basename(zip_path)} {len(batch):4d} noter {len(seen):3d} bilag {mb:6.1f} MB")
|
||||||
|
|
||||||
|
print(f"\nLigger i:\n {out_dir}")
|
||||||
|
print("\nImportér én ad gangen under Indstillinger → Data → Noter.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -191,6 +191,9 @@
|
|||||||
|
|
||||||
let suggestions = $state([]);
|
let suggestions = $state([]);
|
||||||
let chosen = $state(0);
|
let chosen = $state(0);
|
||||||
|
// Hvor forslagslisten skal stå, og wrappen den måles imod.
|
||||||
|
let menuAt = $state(null);
|
||||||
|
let wrap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The word being typed after a #, if that is what is happening.
|
* The word being typed after a #, if that is what is happening.
|
||||||
@@ -210,6 +213,33 @@
|
|||||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvor `#` står, målt i wrappens koordinater.
|
||||||
|
*
|
||||||
|
* Målt på selve tegnet og ikke på markøren: et sammenfaldet område har ingen
|
||||||
|
* kasse på en tom linje, mens `#term` altid har en, fordi der står noget i den.
|
||||||
|
*
|
||||||
|
* Klemt inden for ruden, så en note skrevet helt ude i højre side ikke sender
|
||||||
|
* menuen ud over kanten, og vendt op over linjen, når der ikke er plads under.
|
||||||
|
*/
|
||||||
|
function tagPosition(partial) {
|
||||||
|
if (!wrap) return null;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(partial.node, Math.max(0, partial.start - 1));
|
||||||
|
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||||
|
const at = range.getBoundingClientRect();
|
||||||
|
const box = wrap.getBoundingClientRect();
|
||||||
|
if (!at.width && !at.height) return null;
|
||||||
|
|
||||||
|
const MENU = { w: 210, h: 240 };
|
||||||
|
const below = at.bottom - box.top + 6;
|
||||||
|
const flip = at.bottom + MENU.h > box.bottom && at.top - box.top > MENU.h;
|
||||||
|
return {
|
||||||
|
top: flip ? at.top - box.top - MENU.h - 6 : below,
|
||||||
|
left: Math.max(0, Math.min(at.left - box.left, box.width - MENU.w))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readSuggestions() {
|
function readSuggestions() {
|
||||||
const partial = partialTag();
|
const partial = partialTag();
|
||||||
if (!partial) {
|
if (!partial) {
|
||||||
@@ -218,6 +248,12 @@
|
|||||||
}
|
}
|
||||||
const term = partial.term.toLowerCase();
|
const term = partial.term.toLowerCase();
|
||||||
|
|
||||||
|
// Målt når menuen åbner, ikke ved hvert tastetryk. Den skal stå ved det `#`,
|
||||||
|
// man skriver — den stod før øverst i ruden, hvilket i en lang note er et
|
||||||
|
// helt andet sted end det, øjet er — men den må heller ikke rykke sig et par
|
||||||
|
// pixels for hvert bogstav, mens listen snævres ind.
|
||||||
|
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||||
|
|
||||||
// Alle der passer, ikke de seks første.
|
// Alle der passer, ikke de seks første.
|
||||||
//
|
//
|
||||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
||||||
@@ -371,7 +407,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap" bind:this={wrap}>
|
||||||
<!-- Nothing in here may take focus. A click that moves focus out of the editor
|
<!-- Nothing in here may take focus. A click that moves focus out of the editor
|
||||||
collapses the selection first, so the format lands on nothing — the button
|
collapses the selection first, so the format lands on nothing — the button
|
||||||
appears to do nothing at all, which is how it read the first time. -->
|
appears to do nothing at all, which is how it read the first time. -->
|
||||||
@@ -475,10 +511,15 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
{#if suggestions.length}
|
{#if suggestions.length}
|
||||||
<!-- Anchored to the editor rather than to the caret. Following the caret means
|
<!-- Ved det `#`, der bliver skrevet. Målt én gang, da menuen åbnede, så den
|
||||||
measuring it, and a list that jumps a few pixels as you type is worse than
|
står stille, mens listen snævres ind. -->
|
||||||
one that sits still. -->
|
<ul
|
||||||
<ul class="suggestions" role="listbox" bind:this={list}>
|
class="suggestions"
|
||||||
|
role="listbox"
|
||||||
|
bind:this={list}
|
||||||
|
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||||
|
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||||
|
>
|
||||||
{#each suggestions as project, i (project.id)}
|
{#each suggestions as project, i (project.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user