Writing passport: evidence of process instead of an AI score
She's submitting work that gets run through an AI detector and wants to pre-check she won't be wrongly flagged. Petal should not answer that with a detector of its own: they misfire badly on non-native English (Stanford 2023 found >50% of TOEFL essays flagged as AI vs. near-zero for native writers), so a percentage aimed at an ESL writer is worse than nothing — it either scares her off her own voice or gives false comfort. So the artifact is provenance, not a verdict. Petal already snapshots every ~3 minutes; this turns that history into a standalone printable report: session breakdown, word-count growth, span, active time. No score is emitted anywhere. Two schema additions back it. preserve_history opts a document out of the 40-snapshot prune cap — right for recovery, wrong for provenance, where you want the whole span including the oldest rows. content_hash/prev_hash chain each snapshot to the one before it, so a history edited or thinned after the fact fails verification. Pruning legitimately severs links, so a link break reports as "gaps" unless preserve_history is on; only a hash that fails against its own contents is unconditionally "broken". The chart's x axis is snapshot order, not wall-clock, and that is the load -bearing decision. On a linear time axis an essay written in three sittings across three days renders as three vertical cliffs separated by empty space — visually identical to text pasted in three chunks, i.e. the report would have argued the opposite of the truth. Breaks are compressed into explicitly labelled gutters instead. TestChartGivesWidthToWriting pins it. The report volunteers its largest single word-count jump and states its own limits: it cannot show who was at the keyboard, or whether typed text was composed or copied in. Overclaiming would be self-defeating — a reader who catches it overstating discounts all of it. HTML rather than server-rendered PDF, as with the other exports: a CJK-safe PDF needs an embedded Unicode font or a headless browser. Print styles are there so the browser's Save as PDF is the handoff path. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -365,6 +365,31 @@ SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, s
|
||||
DROP TABLE suggestions;
|
||||
ALTER TABLE suggestions_new RENAME TO suggestions;
|
||||
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Writing passport: evidence that a document was written, not pasted.
|
||||
//
|
||||
// `preserve_history` opts a document out of auto-snapshot pruning. The
|
||||
// 40-snapshot cap is right for recovery (you want recent states) but
|
||||
// wrong for provenance (you want the *whole* span, oldest included), so
|
||||
// a writer who may need to defend authorship flags the doc and keeps
|
||||
// every snapshot.
|
||||
//
|
||||
// `content_hash`/`prev_hash` chain each snapshot to the one before it:
|
||||
// hash = sha256(prev_hash | doc_id | created_at | word_count | text).
|
||||
// This proves the local history is internally consistent — no snapshot
|
||||
// was edited, reordered, or removed after the fact without breaking
|
||||
// every link downstream. It is NOT third-party attestation: anyone with
|
||||
// the DB and the algorithm could forge a fresh chain. It raises the cost
|
||||
// of a doctored history from "edit one row" to "rebuild all of them".
|
||||
// Pre-existing snapshots keep empty hashes and are reported as
|
||||
// unverifiable rather than as failures.
|
||||
name: "0009_writing_passport",
|
||||
stmt: `
|
||||
ALTER TABLE documents ADD COLUMN preserve_history INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE document_versions ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE document_versions ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ type Document struct {
|
||||
WordCount int `json:"word_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// PreserveHistory opts this document out of auto-snapshot pruning so its
|
||||
// full writing trail survives as authorship evidence (see the passport).
|
||||
PreserveHistory bool `json:"preserve_history"`
|
||||
}
|
||||
|
||||
// DocumentVersion is a point-in-time snapshot of a document's body, captured so
|
||||
@@ -42,6 +46,13 @@ type DocumentVersion struct {
|
||||
WordCount int `json:"word_count"`
|
||||
Kind string `json:"kind"` // auto | manual | pre_restore
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// ContentHash chains this snapshot to the previous one (PrevHash), so a
|
||||
// history that was edited or thinned after the fact fails verification.
|
||||
// Both are empty for snapshots taken before the chain existed. Omitted from
|
||||
// list responses; the passport loads them explicitly.
|
||||
ContentHash string `json:"content_hash,omitempty"`
|
||||
PrevHash string `json:"prev_hash,omitempty"`
|
||||
}
|
||||
|
||||
// Document version kinds, mirrored from the schema CHECK constraint.
|
||||
|
||||
@@ -139,6 +139,10 @@ type updateRequest struct {
|
||||
ContentText *string `json:"content_text"`
|
||||
Tone *string `json:"tone"`
|
||||
WordCount *int `json:"word_count"`
|
||||
|
||||
// PreserveHistory toggles the passport's keep-everything mode. Sent alone
|
||||
// by the History panel's toggle, never by the auto-save path.
|
||||
PreserveHistory *bool `json:"preserve_history"`
|
||||
}
|
||||
|
||||
// update applies the provided fields to a document and returns the saved row.
|
||||
@@ -159,9 +163,11 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
content_text = COALESCE(?, content_text),
|
||||
tone = COALESCE(?, tone),
|
||||
word_count = COALESCE(?, word_count),
|
||||
preserve_history = COALESCE(?, preserve_history),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
|
||||
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount,
|
||||
req.PreserveHistory, id, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
@@ -211,13 +217,14 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) fetch(id string) (db.Document, error) {
|
||||
var doc db.Document
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
|
||||
`SELECT id, user_id, title, content, content_text, tone, word_count,
|
||||
created_at, updated_at, preserve_history
|
||||
FROM documents
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
).Scan(
|
||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.PreserveHistory,
|
||||
)
|
||||
return doc, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package docs
|
||||
|
||||
// Writing passport: a standalone, printable report showing *how* a document was
|
||||
// written — when each snapshot landed, how the word count grew, how the work
|
||||
// broke into sessions. It exists because automated "AI detector" verdicts are
|
||||
// unreliable and skew against non-native English writers, so the useful thing to
|
||||
// hand someone who doubts your authorship is not a score but a record.
|
||||
//
|
||||
// The report is deliberately modest about what it proves (see passportLimits):
|
||||
// it evidences a plausible writing process, it does not certify one.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
)
|
||||
|
||||
// Passport tuning.
|
||||
const (
|
||||
// sessionGap is the idle time that separates one writing session from the
|
||||
// next. Auto-snapshots fire at most every 3 minutes while typing, so any
|
||||
// gap far above that means the writer stepped away. 45 minutes keeps a
|
||||
// coffee break inside one session but splits morning from evening work.
|
||||
sessionGap = 45 * time.Minute
|
||||
|
||||
// jumpNoteThreshold is the share of the final word count a single
|
||||
// snapshot-to-snapshot increase must exceed before the report calls it out.
|
||||
// A large jump is the first thing a skeptical reader will ask about, so the
|
||||
// report raises it rather than leaving it to be discovered.
|
||||
jumpNoteThreshold = 0.25
|
||||
)
|
||||
|
||||
// chainHash links a snapshot to its predecessor. Covering prev_hash makes each
|
||||
// hash depend on the entire history before it, so altering any earlier snapshot
|
||||
// invalidates every later one; covering created_at means a row cannot be
|
||||
// silently backdated.
|
||||
//
|
||||
// This detects tampering with the local database. It is not third-party
|
||||
// attestation — someone with the database and this function could regenerate a
|
||||
// consistent chain from scratch.
|
||||
func chainHash(prevHash, docID string, createdAt time.Time, wordCount int, text string) string {
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%s\x00%s\x00%d\x00%d\x00%s",
|
||||
prevHash, docID, createdAt.UTC().UnixNano(), wordCount, text)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// --- report model -----------------------------------------------------------
|
||||
|
||||
// passportSession is one continuous stretch of work — snapshots with no
|
||||
// sessionGap-sized pause between them.
|
||||
type passportSession struct {
|
||||
Start, End time.Time
|
||||
Snapshots int
|
||||
WordsAdded int // net change across the session; negative when trimming
|
||||
}
|
||||
|
||||
// Duration is the observed length of the session: first snapshot to last. A
|
||||
// single-snapshot session reports zero, which is why total active time is
|
||||
// described as a lower bound.
|
||||
func (s passportSession) Duration() time.Duration { return s.End.Sub(s.Start) }
|
||||
|
||||
// chain verification outcomes, in the order the report prefers to report them.
|
||||
const (
|
||||
chainVerified = "verified" // every hash recomputes and every link holds
|
||||
chainGaps = "gaps" // hashes valid, links broken — consistent with pruning
|
||||
chainPartial = "partial" // some snapshots predate the hash chain
|
||||
chainUnverifiable = "unverifiable" // no snapshot carries a hash
|
||||
chainBroken = "broken" // a hash does not match its own contents
|
||||
)
|
||||
|
||||
// passportData is everything the template renders.
|
||||
type passportData struct {
|
||||
Doc db.Document
|
||||
Versions []db.DocumentVersion // ascending by time
|
||||
|
||||
Sessions []passportSession
|
||||
FirstAt time.Time
|
||||
LastAt time.Time
|
||||
Span time.Duration // wall-clock first snapshot → last
|
||||
ActiveTime time.Duration // summed session durations; a lower bound
|
||||
|
||||
LargestJump int // biggest single snapshot-to-snapshot word increase
|
||||
LargestJumpAt time.Time
|
||||
LargestJumpIdx int // index into Versions, so the chart can mark it
|
||||
NoteJump bool // jump is large enough to be worth pre-empting
|
||||
|
||||
ChainStatus string
|
||||
UnhashedCount int
|
||||
GeneratedAt time.Time
|
||||
}
|
||||
|
||||
// buildPassport derives the report from a document and its snapshots, which must
|
||||
// be ordered oldest-first. It assumes nothing about snapshot spacing.
|
||||
func buildPassport(doc db.Document, versions []db.DocumentVersion) passportData {
|
||||
d := passportData{
|
||||
Doc: doc,
|
||||
Versions: versions,
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
d.ChainStatus = chainUnverifiable
|
||||
return d
|
||||
}
|
||||
|
||||
d.FirstAt = versions[0].CreatedAt
|
||||
d.LastAt = versions[len(versions)-1].CreatedAt
|
||||
d.Span = d.LastAt.Sub(d.FirstAt)
|
||||
|
||||
cur := passportSession{Start: versions[0].CreatedAt, End: versions[0].CreatedAt, Snapshots: 1}
|
||||
|
||||
// Baseline for the running session's net-words figure. Later sessions
|
||||
// measure from the *previous* session's final count, not from their own
|
||||
// first snapshot, because that first snapshot already contains the few
|
||||
// minutes of typing that preceded it — measuring from it would drop that
|
||||
// work. The first session is the exception: it measures from its own first
|
||||
// snapshot rather than from zero, so a history whose early snapshots were
|
||||
// pruned understates session one instead of reporting the words it never
|
||||
// saw as a sudden addition.
|
||||
startWords := versions[0].WordCount
|
||||
|
||||
for i := 1; i < len(versions); i++ {
|
||||
v, prev := versions[i], versions[i-1]
|
||||
|
||||
if delta := v.WordCount - prev.WordCount; delta > d.LargestJump {
|
||||
d.LargestJump, d.LargestJumpAt, d.LargestJumpIdx = delta, v.CreatedAt, i
|
||||
}
|
||||
|
||||
if v.CreatedAt.Sub(prev.CreatedAt) > sessionGap {
|
||||
cur.WordsAdded = prev.WordCount - startWords
|
||||
d.Sessions = append(d.Sessions, cur)
|
||||
cur = passportSession{Start: v.CreatedAt, End: v.CreatedAt, Snapshots: 1}
|
||||
startWords = prev.WordCount
|
||||
continue
|
||||
}
|
||||
cur.End = v.CreatedAt
|
||||
cur.Snapshots++
|
||||
}
|
||||
cur.WordsAdded = versions[len(versions)-1].WordCount - startWords
|
||||
d.Sessions = append(d.Sessions, cur)
|
||||
|
||||
for _, s := range d.Sessions {
|
||||
d.ActiveTime += s.Duration()
|
||||
}
|
||||
|
||||
final := versions[len(versions)-1].WordCount
|
||||
d.NoteJump = final > 0 && float64(d.LargestJump)/float64(final) > jumpNoteThreshold
|
||||
|
||||
d.ChainStatus, d.UnhashedCount = verifyChain(doc, versions)
|
||||
return d
|
||||
}
|
||||
|
||||
// verifyChain recomputes every snapshot's hash and checks that each links to the
|
||||
// one before it. Returns the outcome and how many snapshots predate the chain.
|
||||
//
|
||||
// Broken *links* are not evidence of tampering on their own: auto-snapshot
|
||||
// pruning legitimately removes rows from the middle of the history, which severs
|
||||
// the links across the hole. So a link break is reported as a gap unless the
|
||||
// document is in preserve-history mode, where nothing should ever be removed. A
|
||||
// hash that fails to match its *own* contents is unambiguous, and always broken.
|
||||
func verifyChain(doc db.Document, versions []db.DocumentVersion) (status string, unhashed int) {
|
||||
var (
|
||||
hashed int
|
||||
linkBreak bool
|
||||
prevHash string
|
||||
havePrev bool
|
||||
)
|
||||
|
||||
for _, v := range versions {
|
||||
if v.ContentHash == "" {
|
||||
unhashed++
|
||||
havePrev = false // can't vouch for what follows an unhashed row
|
||||
continue
|
||||
}
|
||||
hashed++
|
||||
|
||||
want := chainHash(v.PrevHash, v.DocID, v.CreatedAt, v.WordCount, v.ContentText)
|
||||
if want != v.ContentHash {
|
||||
return chainBroken, unhashed
|
||||
}
|
||||
if havePrev && v.PrevHash != prevHash {
|
||||
linkBreak = true
|
||||
}
|
||||
prevHash, havePrev = v.ContentHash, true
|
||||
}
|
||||
|
||||
switch {
|
||||
case hashed == 0:
|
||||
return chainUnverifiable, unhashed
|
||||
case linkBreak && doc.PreserveHistory:
|
||||
// Nothing should have been removed from a preserved history.
|
||||
return chainBroken, unhashed
|
||||
case linkBreak:
|
||||
return chainGaps, unhashed
|
||||
case unhashed > 0:
|
||||
return chainPartial, unhashed
|
||||
default:
|
||||
return chainVerified, unhashed
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP -------------------------------------------------------------------
|
||||
|
||||
// passport renders the report for one document as a standalone HTML download.
|
||||
// HTML rather than PDF for the same reason as the other exports: a CJK-safe PDF
|
||||
// needs an embedded Unicode font or a headless browser. The page is styled for
|
||||
// printing, so "Save as PDF" in the browser produces the handoff artifact.
|
||||
func (h *Handler) passport(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
doc, err := h.fetch(docID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
notFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
versions, err := h.passportVersions(docID)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
body := renderPassport(buildPassport(doc, versions))
|
||||
|
||||
filename := sanitizeFilename(doc.Title)
|
||||
if filename == "" {
|
||||
filename = "untitled"
|
||||
}
|
||||
filename += " - writing passport.html"
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// passportVersions loads every snapshot oldest-first with the fields the report
|
||||
// and the chain check need — including content_text, which the list endpoint
|
||||
// omits as too heavy but verification cannot do without.
|
||||
func (h *Handler) passportVersions(docID string) ([]db.DocumentVersion, error) {
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT v.id, v.doc_id, v.title, v.content_text, v.word_count, v.kind,
|
||||
v.created_at, v.content_hash, v.prev_hash
|
||||
FROM document_versions v
|
||||
JOIN documents d ON d.id = v.doc_id
|
||||
WHERE v.doc_id = ? AND d.user_id = ?
|
||||
ORDER BY v.created_at ASC, v.rowid ASC`,
|
||||
docID, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []db.DocumentVersion
|
||||
for rows.Next() {
|
||||
var v db.DocumentVersion
|
||||
if err := rows.Scan(
|
||||
&v.ID, &v.DocID, &v.Title, &v.ContentText, &v.WordCount, &v.Kind,
|
||||
&v.CreatedAt, &v.ContentHash, &v.PrevHash,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package docs
|
||||
|
||||
// HTML rendering for the writing passport. Self-contained (no external assets)
|
||||
// and styled for print, so the browser's "Save as PDF" turns it into the file a
|
||||
// writer actually hands over.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chart geometry. The plot is wide and short on purpose: the report's question
|
||||
// is "what shape did this document grow in", and a wide aspect makes a steady
|
||||
// climb read as steady rather than dramatic.
|
||||
const (
|
||||
chartW, chartH = 760, 260
|
||||
padL, padR, padT, padB = 52, 20, 18, 34
|
||||
plotW, plotH = chartW - padL - padR, chartH - padT - padB
|
||||
minBandW = 2.0 // so a single-snapshot session still shows
|
||||
|
||||
// gutterSlots is the space between sessions, in snapshot-slot widths. Wide
|
||||
// enough to read as a break and to seat its duration label.
|
||||
gutterSlots = 2.5
|
||||
)
|
||||
|
||||
// Palette — the export stylesheet's tokens, reused so a passport looks like it
|
||||
// came from the same application as the document it describes.
|
||||
const (
|
||||
rose = "#b04a6a"
|
||||
roseLight = "#f6d6e0"
|
||||
roseWash = "#fdeef3"
|
||||
surface = "#fffafb"
|
||||
)
|
||||
|
||||
func renderPassport(d passportData) []byte {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, passportHead, htmlEscape(d.Doc.Title))
|
||||
|
||||
fmt.Fprintf(&b, `<header>
|
||||
<p class="eyebrow">Writing passport</p>
|
||||
<h1>%s</h1>
|
||||
<p class="sub">Generated %s</p>
|
||||
</header>
|
||||
`, htmlEscape(d.Doc.Title), htmlEscape(formatWhen(d.GeneratedAt)))
|
||||
|
||||
if len(d.Versions) == 0 {
|
||||
b.WriteString(`<p class="empty">This document has no saved history yet, so there is
|
||||
nothing to report. History builds up automatically as you write.</p>
|
||||
</body></html>`)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
b.WriteString(renderStats(d))
|
||||
b.WriteString(renderChart(d))
|
||||
b.WriteString(renderSessions(d))
|
||||
b.WriteString(renderIntegrity(d))
|
||||
b.WriteString(passportLimits)
|
||||
b.WriteString("</body></html>\n")
|
||||
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// renderStats is the headline row — the numbers a reader wants before deciding
|
||||
// whether to study the chart.
|
||||
func renderStats(d passportData) string {
|
||||
final := d.Versions[len(d.Versions)-1].WordCount
|
||||
|
||||
tiles := []struct{ value, label string }{
|
||||
{fmt.Sprintf("%d", len(d.Versions)), "snapshots saved"},
|
||||
{humanDuration(d.Span), "from first to last edit"},
|
||||
{fmt.Sprintf("%d", len(d.Sessions)), pluralize(len(d.Sessions), "writing session", "writing sessions")},
|
||||
{humanDuration(d.ActiveTime), "spent actively editing"},
|
||||
{fmt.Sprintf("%d", final), "words in the final draft"},
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<section class="stats">`)
|
||||
for _, t := range tiles {
|
||||
fmt.Fprintf(&b, `<div class="tile"><span class="v">%s</span><span class="l">%s</span></div>`,
|
||||
htmlEscape(t.value), htmlEscape(t.label))
|
||||
}
|
||||
b.WriteString("</section>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderChart draws word count as a step line — the count is only known at
|
||||
// snapshot moments, and a step says that honestly where a smooth curve would
|
||||
// invent values in between. Shaded bands mark writing sessions.
|
||||
//
|
||||
// The x axis is snapshot order, not wall-clock time, and this is deliberate. On
|
||||
// a linear time axis an essay written in three half-hour sittings across three
|
||||
// days renders as three vertical cliffs separated by empty space: all the actual
|
||||
// writing is crushed into one percent of the width, and the result looks exactly
|
||||
// like text pasted in three chunks — the opposite of what happened. Because
|
||||
// auto-snapshots are throttled to roughly one per few minutes of *active*
|
||||
// editing, snapshot order is already close to proportional to time spent
|
||||
// writing. So the plot gives its width to the writing and compresses the breaks,
|
||||
// which are drawn as explicit labelled gaps rather than silently removed.
|
||||
//
|
||||
// One series, so no legend: the heading names it.
|
||||
func renderChart(d passportData) string {
|
||||
maxW := 0
|
||||
for _, v := range d.Versions {
|
||||
if v.WordCount > maxW {
|
||||
maxW = v.WordCount
|
||||
}
|
||||
}
|
||||
yTop := niceCeil(maxW)
|
||||
|
||||
y := func(words int) float64 {
|
||||
if yTop <= 0 {
|
||||
return padT + plotH
|
||||
}
|
||||
return padT + plotH - float64(words)/float64(yTop)*plotH
|
||||
}
|
||||
|
||||
// Lay snapshots out in slots: one per snapshot, plus a gutter between
|
||||
// sessions for the break marker.
|
||||
slots := float64(len(d.Versions)) + gutterSlots*float64(len(d.Sessions)-1)
|
||||
sw := plotW / slots
|
||||
|
||||
xs := make([]float64, len(d.Versions))
|
||||
bandStart := make([]float64, len(d.Sessions))
|
||||
bandEnd := make([]float64, len(d.Sessions))
|
||||
|
||||
cursor, vi := 0.0, 0
|
||||
for si, s := range d.Sessions {
|
||||
if si > 0 {
|
||||
cursor += gutterSlots
|
||||
}
|
||||
bandStart[si] = padL + cursor*sw
|
||||
for k := 0; k < s.Snapshots; k++ {
|
||||
xs[vi] = padL + (cursor+0.5)*sw
|
||||
cursor++
|
||||
vi++
|
||||
}
|
||||
bandEnd[si] = padL + cursor*sw
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<section class="chart">
|
||||
<h2>How the draft grew</h2>
|
||||
<svg viewBox="0 0 %d %d" role="img" aria-label="Word count at each saved snapshot, grouped into writing sessions">
|
||||
`, chartW, chartH)
|
||||
|
||||
// Session bands sit behind everything; each carries a native tooltip.
|
||||
for i, s := range d.Sessions {
|
||||
w := bandEnd[i] - bandStart[i]
|
||||
if w < minBandW {
|
||||
w = minBandW
|
||||
}
|
||||
fmt.Fprintf(&b, `<rect x="%.1f" y="%d" width="%.1f" height="%d" fill="%s" rx="3"><title>Session %d: %s, %s, %d snapshots</title></rect>
|
||||
`, bandStart[i], padT, w, plotH, roseWash, i+1,
|
||||
htmlEscape(formatWhen(s.Start)), htmlEscape(humanDuration(s.Duration())), s.Snapshots)
|
||||
}
|
||||
|
||||
// Recessive gridlines with y labels at 0 / half / top.
|
||||
for _, gv := range []int{0, yTop / 2, yTop} {
|
||||
gy := y(gv)
|
||||
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="1"/>
|
||||
<text x="%d" y="%.1f" class="axis" text-anchor="end">%d</text>
|
||||
`, padL, gy, chartW-padR, gy, roseLight, padL-8, gy+4, gv)
|
||||
}
|
||||
|
||||
// Break markers in the gutters, so compressed time is stated, not hidden.
|
||||
for i := 1; i < len(d.Sessions); i++ {
|
||||
mid := (bandEnd[i-1] + bandStart[i]) / 2
|
||||
gap := d.Sessions[i].Start.Sub(d.Sessions[i-1].End)
|
||||
fmt.Fprintf(&b, `<line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="%s" stroke-width="1" stroke-dasharray="3 3"/>
|
||||
<text x="%.1f" y="%d" class="gap" text-anchor="middle">%s</text>
|
||||
`, mid, padT, mid, padT+plotH, roseLight, mid, padT+plotH+13, htmlEscape(humanDuration(gap)+" away"))
|
||||
}
|
||||
|
||||
// Step path: hold the previous value until the next snapshot lands.
|
||||
var path strings.Builder
|
||||
fmt.Fprintf(&path, "M %.1f %.1f", xs[0], y(d.Versions[0].WordCount))
|
||||
for i := 1; i < len(d.Versions); i++ {
|
||||
fmt.Fprintf(&path, " L %.1f %.1f L %.1f %.1f",
|
||||
xs[i], y(d.Versions[i-1].WordCount), xs[i], y(d.Versions[i].WordCount))
|
||||
}
|
||||
fmt.Fprintf(&b, `<path d="%s" fill="none" stroke="%s" stroke-width="2" stroke-linejoin="round"/>
|
||||
`, path.String(), rose)
|
||||
|
||||
// Pre-empt the obvious question: label the largest single jump when it is a
|
||||
// big share of the finished draft, rather than letting a reader find it.
|
||||
if d.NoteJump && d.LargestJumpIdx < len(xs) {
|
||||
jx, jy := xs[d.LargestJumpIdx], y(d.Versions[d.LargestJumpIdx].WordCount)
|
||||
|
||||
// Flip the label inboard near the right edge so it can't overflow, and
|
||||
// push it below the point when the point sits near the top.
|
||||
anchor, dx := "start", 9.0
|
||||
if jx > float64(chartW)*0.6 {
|
||||
anchor, dx = "end", -9.0
|
||||
}
|
||||
ly := jy - 10
|
||||
if ly < padT+12 {
|
||||
ly = jy + 18
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="4" fill="%s" stroke="%s" stroke-width="2"/>
|
||||
<text x="%.1f" y="%.1f" class="note" text-anchor="%s">largest single addition: +%d words</text>
|
||||
`, jx, jy, rose, surface, jx+dx, ly, anchor, d.LargestJump)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, `</svg>
|
||||
<p class="caption">Each shaded band is one writing session, %s to %s. Width follows
|
||||
snapshots saved, so time spent writing gets the space and breaks are compressed to
|
||||
the labelled gaps.</p>
|
||||
</section>
|
||||
`, htmlEscape(formatDay(d.FirstAt)), htmlEscape(formatDay(d.LastAt)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderSessions(d passportData) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<section>
|
||||
<h2>Writing sessions</h2>
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Started</th><th>Length</th><th>Snapshots</th><th>Net words</th></tr></thead>
|
||||
<tbody>
|
||||
`)
|
||||
for i, s := range d.Sessions {
|
||||
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s</td><td>%s</td><td>%d</td><td>%+d</td></tr>
|
||||
`, i+1, htmlEscape(formatWhen(s.Start)), htmlEscape(humanDuration(s.Duration())),
|
||||
s.Snapshots, s.WordsAdded)
|
||||
}
|
||||
b.WriteString("</tbody></table>\n</section>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderIntegrity explains the hash chain in plain language, including when it
|
||||
// cannot vouch for something.
|
||||
func renderIntegrity(d passportData) string {
|
||||
var headline, detail string
|
||||
|
||||
switch d.ChainStatus {
|
||||
case chainVerified:
|
||||
headline = "History intact"
|
||||
detail = "Every snapshot matches its own contents and links correctly to the one before it. Nothing in this history has been altered or removed since it was recorded."
|
||||
case chainGaps:
|
||||
headline = "History intact, with gaps"
|
||||
detail = "Every snapshot matches its own contents, but some older automatic snapshots have been cleared to save space, so the record is not continuous. Turn on “keep full history” for this document to stop that happening."
|
||||
case chainPartial:
|
||||
headline = "Partly verifiable"
|
||||
detail = fmt.Sprintf("%d snapshot(s) were recorded before this document started tracking integrity, so they cannot be checked. Everything recorded since then matches.", d.UnhashedCount)
|
||||
case chainUnverifiable:
|
||||
headline = "Not verifiable"
|
||||
detail = "These snapshots were recorded before integrity tracking existed. The timeline above is still the record that was saved as you wrote; it simply cannot be checked for later alteration."
|
||||
case chainBroken:
|
||||
headline = "Integrity check failed"
|
||||
detail = "At least one snapshot does not match what was recorded for it. This can mean the history was edited after the fact, or that the database was restored from a backup or copied between machines."
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<section class="integrity %s">
|
||||
<h2>%s</h2>
|
||||
<p>%s</p>
|
||||
</section>
|
||||
`, htmlEscape(d.ChainStatus), htmlEscape(headline), htmlEscape(detail))
|
||||
}
|
||||
|
||||
// passportLimits states plainly what the report does and does not establish.
|
||||
// Overclaiming would be worse than useless: a reader who catches the report
|
||||
// overstating its case discounts the whole thing.
|
||||
const passportLimits = `<section class="limits">
|
||||
<h2>How to read this</h2>
|
||||
<p>A document written over time leaves a trail: many snapshots, uneven growth,
|
||||
words added and cut and added again across separate sittings. A document that was
|
||||
pasted in from elsewhere tends to arrive nearly whole, in one or two snapshots,
|
||||
with little revision after.</p>
|
||||
<p>What this report shows is the record Petal saved automatically while the
|
||||
document was open, roughly every few minutes of active editing.</p>
|
||||
<p><strong>What it does not show.</strong> It cannot prove who was at the
|
||||
keyboard, and it cannot tell whether text typed into the editor was composed
|
||||
there or copied from another window. It is evidence of a writing process, not a
|
||||
certificate of authorship. It is most useful read alongside the drafts
|
||||
themselves.</p>
|
||||
</section>
|
||||
`
|
||||
|
||||
// --- formatting helpers -----------------------------------------------------
|
||||
|
||||
func formatWhen(t time.Time) string { return t.Local().Format("2 Jan 2006, 3:04 PM") }
|
||||
func formatDay(t time.Time) string { return t.Local().Format("2 Jan 2006") }
|
||||
|
||||
// humanDuration renders a span at the coarsest useful precision — a reader cares
|
||||
// that a session ran "2h 40m", never that it ran 2h40m12s.
|
||||
func humanDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return "under a minute"
|
||||
}
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
mins := int(d.Minutes()) % 60
|
||||
|
||||
switch {
|
||||
case days > 0 && hours > 0:
|
||||
return fmt.Sprintf("%dd %dh", days, hours)
|
||||
case days > 0:
|
||||
return fmt.Sprintf("%dd", days)
|
||||
case hours > 0 && mins > 0:
|
||||
return fmt.Sprintf("%dh %dm", hours, mins)
|
||||
case hours > 0:
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
default:
|
||||
return fmt.Sprintf("%dm", mins)
|
||||
}
|
||||
}
|
||||
|
||||
func pluralize(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return one
|
||||
}
|
||||
return many
|
||||
}
|
||||
|
||||
// niceCeil rounds a maximum up to a round number so gridlines land on values a
|
||||
// reader can hold in their head.
|
||||
func niceCeil(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
mag := math.Pow(10, math.Floor(math.Log10(float64(n))))
|
||||
return int(math.Ceil(float64(n)/(mag/2)) * (mag / 2))
|
||||
}
|
||||
|
||||
// passportHead is the page shell: one %s for the title. Print rules keep the
|
||||
// chart and the caveats on the page rather than letting them break across sheets.
|
||||
const passportHead = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Writing passport — %s</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
body {
|
||||
font-family: "Georgia", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", serif;
|
||||
line-height: 1.7; color: #463a3f; background: #fffafb;
|
||||
max-width: 48rem; margin: 3rem auto; padding: 0 1.5rem;
|
||||
}
|
||||
header { border-bottom: 2px solid #f6d6e0; padding-bottom: 1rem; margin-bottom: 2rem; }
|
||||
.eyebrow { text-transform: uppercase; letter-spacing: .12em; font-size: .72rem;
|
||||
color: #b04a6a; margin: 0 0 .3rem; }
|
||||
h1 { font-size: 1.8rem; color: #b04a6a; margin: 0; line-height: 1.3; }
|
||||
h2 { font-size: 1.05rem; color: #b04a6a; margin: 0 0 .75rem; }
|
||||
.sub, .axis, .l { color: #6b5860; }
|
||||
.sub { margin: .4rem 0 0; font-size: .9rem; }
|
||||
section { margin: 2.25rem 0; }
|
||||
|
||||
.stats { display: flex; flex-wrap: wrap; gap: 1.25rem 2rem; margin: 2rem 0; }
|
||||
.tile { display: flex; flex-direction: column; min-width: 7rem; }
|
||||
.tile .v { font-size: 1.6rem; color: #b04a6a; line-height: 1.1; }
|
||||
.tile .l { font-size: .8rem; margin-top: .15rem; }
|
||||
|
||||
.chart svg { width: 100%%; height: auto; }
|
||||
.axis { font-size: 11px; fill: #6b5860; font-family: system-ui, sans-serif; }
|
||||
.note { font-size: 11px; fill: #463a3f; font-family: system-ui, sans-serif; }
|
||||
.gap { font-size: 10px; fill: #6b5860; font-family: system-ui, sans-serif; }
|
||||
.caption { font-size: .8rem; color: #6b5860; margin: .5rem 0 0; }
|
||||
|
||||
table { border-collapse: collapse; width: 100%%; font-size: .9rem; }
|
||||
th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid #f3cdd9; }
|
||||
th { color: #6b5860; font-weight: normal; font-size: .78rem;
|
||||
text-transform: uppercase; letter-spacing: .06em; }
|
||||
|
||||
.integrity { background: #fff2f6; border-left: 3px solid #f3b6c8;
|
||||
padding: 1rem 1.25rem; border-radius: .4rem; }
|
||||
.integrity.broken { border-left-color: #c2410c; }
|
||||
.integrity p { margin: 0; font-size: .92rem; }
|
||||
|
||||
.limits { font-size: .88rem; color: #6b5860; border-top: 1px solid #f3cdd9;
|
||||
padding-top: 1.25rem; }
|
||||
.limits strong { color: #463a3f; }
|
||||
.empty { color: #6b5860; }
|
||||
|
||||
@media print {
|
||||
body { margin: 0; max-width: none; }
|
||||
section, .chart svg, table { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
`
|
||||
@@ -0,0 +1,376 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// chained builds a valid hash-chained snapshot run from (minutes-offset, words)
|
||||
// pairs, so tests can describe a writing history in terms a reader recognises
|
||||
// and get correct hashes for free.
|
||||
func chained(docID string, base time.Time, points ...[2]int) []db.DocumentVersion {
|
||||
var (
|
||||
out []db.DocumentVersion
|
||||
prev string
|
||||
)
|
||||
for i, p := range points {
|
||||
at := base.Add(time.Duration(p[0]) * time.Minute)
|
||||
text := strings.Repeat("word ", p[1])
|
||||
v := db.DocumentVersion{
|
||||
ID: fmt.Sprintf("v%d", i),
|
||||
DocID: docID,
|
||||
ContentText: text,
|
||||
WordCount: p[1],
|
||||
Kind: db.VersionKindAuto,
|
||||
CreatedAt: at,
|
||||
PrevHash: prev,
|
||||
}
|
||||
v.ContentHash = chainHash(prev, docID, at, p[1], text)
|
||||
prev = v.ContentHash
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBuildPassportSessions(t *testing.T) {
|
||||
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
|
||||
doc := db.Document{ID: "d1", Title: "Essay"}
|
||||
|
||||
// Two sittings: 09:00–09:30, then a three-hour break, then 12:30–13:00.
|
||||
vs := chained("d1", base,
|
||||
[2]int{0, 40}, [2]int{15, 120}, [2]int{30, 210},
|
||||
[2]int{210, 260}, [2]int{240, 330},
|
||||
)
|
||||
|
||||
d := buildPassport(doc, vs)
|
||||
|
||||
if len(d.Sessions) != 2 {
|
||||
t.Fatalf("sessions = %d, want 2", len(d.Sessions))
|
||||
}
|
||||
if got := d.Sessions[0].Duration(); got != 30*time.Minute {
|
||||
t.Errorf("session 1 duration = %v, want 30m", got)
|
||||
}
|
||||
if got := d.Sessions[1].Snapshots; got != 2 {
|
||||
t.Errorf("session 2 snapshots = %d, want 2", got)
|
||||
}
|
||||
if got := d.Span; got != 4*time.Hour {
|
||||
t.Errorf("span = %v, want 4h", got)
|
||||
}
|
||||
// Active time counts only time inside sessions, never the break.
|
||||
if got := d.ActiveTime; got != 60*time.Minute {
|
||||
t.Errorf("active time = %v, want 60m", got)
|
||||
}
|
||||
// Session 2 measures from session 1's final count (210 → 330).
|
||||
if got := d.Sessions[1].WordsAdded; got != 120 {
|
||||
t.Errorf("session 2 words = %d, want 120", got)
|
||||
}
|
||||
if d.ChainStatus != chainVerified {
|
||||
t.Errorf("chain = %q, want %q", d.ChainStatus, chainVerified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPassportFlagsLargeJump(t *testing.T) {
|
||||
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
|
||||
doc := db.Document{ID: "d1"}
|
||||
|
||||
t.Run("steady growth is not flagged", func(t *testing.T) {
|
||||
vs := chained("d1", base, [2]int{0, 100}, [2]int{5, 200}, [2]int{10, 300}, [2]int{15, 400})
|
||||
if d := buildPassport(doc, vs); d.NoteJump {
|
||||
t.Errorf("even growth flagged a jump of %d", d.LargestJump)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a paste-shaped jump is flagged", func(t *testing.T) {
|
||||
vs := chained("d1", base, [2]int{0, 20}, [2]int{5, 40}, [2]int{10, 900})
|
||||
d := buildPassport(doc, vs)
|
||||
if !d.NoteJump {
|
||||
t.Fatal("large jump not flagged")
|
||||
}
|
||||
if d.LargestJump != 860 {
|
||||
t.Errorf("largest jump = %d, want 860", d.LargestJump)
|
||||
}
|
||||
if !d.LargestJumpAt.Equal(base.Add(10 * time.Minute)) {
|
||||
t.Errorf("jump at %v, want +10m", d.LargestJumpAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyChain(t *testing.T) {
|
||||
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
|
||||
good := func() []db.DocumentVersion {
|
||||
return chained("d1", base, [2]int{0, 50}, [2]int{5, 90}, [2]int{10, 160})
|
||||
}
|
||||
|
||||
t.Run("intact chain verifies", func(t *testing.T) {
|
||||
got, _ := verifyChain(db.Document{ID: "d1"}, good())
|
||||
if got != chainVerified {
|
||||
t.Errorf("got %q, want %q", got, chainVerified)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edited content breaks it", func(t *testing.T) {
|
||||
vs := good()
|
||||
vs[1].ContentText = "something else entirely"
|
||||
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainBroken {
|
||||
t.Errorf("got %q, want %q", got, chainBroken)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backdating breaks it", func(t *testing.T) {
|
||||
vs := good()
|
||||
vs[2].CreatedAt = base.Add(-time.Hour)
|
||||
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainBroken {
|
||||
t.Errorf("got %q, want %q", got, chainBroken)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a removed snapshot reads as a gap when pruning is allowed", func(t *testing.T) {
|
||||
vs := good()
|
||||
pruned := []db.DocumentVersion{vs[0], vs[2]} // middle snapshot gone
|
||||
got, _ := verifyChain(db.Document{ID: "d1"}, pruned)
|
||||
if got != chainGaps {
|
||||
t.Errorf("got %q, want %q", got, chainGaps)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a removed snapshot is tampering when history is preserved", func(t *testing.T) {
|
||||
vs := good()
|
||||
pruned := []db.DocumentVersion{vs[0], vs[2]}
|
||||
got, _ := verifyChain(db.Document{ID: "d1", PreserveHistory: true}, pruned)
|
||||
if got != chainBroken {
|
||||
t.Errorf("got %q, want %q", got, chainBroken)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pre-chain snapshots are partial, not failures", func(t *testing.T) {
|
||||
vs := good()
|
||||
vs[0].ContentHash, vs[0].PrevHash = "", ""
|
||||
got, unhashed := verifyChain(db.Document{ID: "d1"}, vs)
|
||||
if got != chainPartial {
|
||||
t.Errorf("got %q, want %q", got, chainPartial)
|
||||
}
|
||||
if unhashed != 1 {
|
||||
t.Errorf("unhashed = %d, want 1", unhashed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no hashes at all is unverifiable", func(t *testing.T) {
|
||||
vs := good()
|
||||
for i := range vs {
|
||||
vs[i].ContentHash, vs[i].PrevHash = "", ""
|
||||
}
|
||||
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainUnverifiable {
|
||||
t.Errorf("got %q, want %q", got, chainUnverifiable)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The report must survive the degenerate histories — one snapshot, an empty
|
||||
// document — rather than dividing by a zero span or a zero maximum.
|
||||
func TestRenderPassportEdgeCases(t *testing.T) {
|
||||
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("no history", func(t *testing.T) {
|
||||
out := string(renderPassport(buildPassport(db.Document{Title: "Empty"}, nil)))
|
||||
if !strings.Contains(out, "no saved history") {
|
||||
t.Errorf("missing empty-state copy:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single snapshot", func(t *testing.T) {
|
||||
vs := chained("d1", base, [2]int{0, 12})
|
||||
out := string(renderPassport(buildPassport(db.Document{ID: "d1", Title: "One"}, vs)))
|
||||
if strings.Contains(out, "NaN") || strings.Contains(out, "+Inf") {
|
||||
t.Errorf("degenerate geometry leaked into output:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty document", func(t *testing.T) {
|
||||
vs := chained("d1", base, [2]int{0, 0}, [2]int{5, 0})
|
||||
out := string(renderPassport(buildPassport(db.Document{ID: "d1"}, vs)))
|
||||
if strings.Contains(out, "NaN") {
|
||||
t.Errorf("zero word count produced NaN:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("title is escaped", func(t *testing.T) {
|
||||
doc := db.Document{ID: "d1", Title: `<script>alert(1)</script>`}
|
||||
out := string(renderPassport(buildPassport(doc, chained("d1", base, [2]int{0, 5}))))
|
||||
if strings.Contains(out, "<script>") {
|
||||
t.Error("title was not escaped")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The chart must give its width to the writing, not to the gaps between
|
||||
// sittings. Three short sessions spread over three days is the case that a
|
||||
// wall-clock x axis renders as three vertical cliffs — visually identical to
|
||||
// pasted text, and wrong.
|
||||
func TestChartGivesWidthToWriting(t *testing.T) {
|
||||
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
// ~30 minutes of work on each of three consecutive days.
|
||||
var pts [][2]int
|
||||
words := 0
|
||||
for day := 0; day < 3; day++ {
|
||||
for k := 0; k < 6; k++ {
|
||||
words += 50
|
||||
pts = append(pts, [2]int{day*1440 + k*5, words})
|
||||
}
|
||||
}
|
||||
|
||||
d := buildPassport(db.Document{ID: "d1"}, chained("d1", base, pts...))
|
||||
if len(d.Sessions) != 3 {
|
||||
t.Fatalf("sessions = %d, want 3", len(d.Sessions))
|
||||
}
|
||||
|
||||
out := renderChart(d)
|
||||
|
||||
// Every session band should be a substantial share of the plot, not a sliver.
|
||||
widths := regexp.MustCompile(`<rect [^>]*width="([0-9.]+)"`).FindAllStringSubmatch(out, -1)
|
||||
if len(widths) != 3 {
|
||||
t.Fatalf("session bands = %d, want 3", len(widths))
|
||||
}
|
||||
for i, m := range widths {
|
||||
w, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w < plotW*0.15 {
|
||||
t.Errorf("session %d band is %.1fpx of %dpx plot — writing got crushed", i+1, w, plotW)
|
||||
}
|
||||
}
|
||||
|
||||
// The compressed breaks must be stated, not silently removed.
|
||||
if got := strings.Count(out, `class="gap"`); got != 2 {
|
||||
t.Errorf("break labels = %d, want 2", got)
|
||||
}
|
||||
if !strings.Contains(out, "away") {
|
||||
t.Error("break labels do not name their duration")
|
||||
}
|
||||
}
|
||||
|
||||
// Chart coordinates must stay inside the viewBox whatever the history looks like.
|
||||
func TestChartStaysInBounds(t *testing.T) {
|
||||
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
histories := map[string][][2]int{
|
||||
"single snapshot": {{0, 30}},
|
||||
"two sessions": {{0, 30}, {5, 90}, {600, 140}, {605, 210}},
|
||||
"words removed": {{0, 400}, {5, 380}, {10, 120}},
|
||||
"all zero": {{0, 0}, {5, 0}},
|
||||
"many snapshots": func() (p [][2]int) {
|
||||
for i := 0; i < 60; i++ {
|
||||
p = append(p, [2]int{i * 4, i * 20})
|
||||
}
|
||||
return
|
||||
}(),
|
||||
}
|
||||
|
||||
num := regexp.MustCompile(`(?:x|cx)="([0-9.-]+)"`)
|
||||
for name, pts := range histories {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
out := renderChart(buildPassport(db.Document{ID: "d1"}, chained("d1", base, pts...)))
|
||||
if strings.Contains(out, "NaN") || strings.Contains(out, "Inf") {
|
||||
t.Fatalf("degenerate geometry:\n%s", out)
|
||||
}
|
||||
for _, m := range num.FindAllStringSubmatch(out, -1) {
|
||||
v, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v < 0 || v > chartW {
|
||||
t.Errorf("x coordinate %.1f outside 0..%d", v, chartW)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanDuration(t *testing.T) {
|
||||
cases := []struct {
|
||||
in time.Duration
|
||||
want string
|
||||
}{
|
||||
{0, "under a minute"},
|
||||
{30 * time.Second, "under a minute"},
|
||||
{18 * time.Minute, "18m"},
|
||||
{2 * time.Hour, "2h"},
|
||||
{2*time.Hour + 40*time.Minute, "2h 40m"},
|
||||
{50 * time.Hour, "2d 2h"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := humanDuration(c.in); got != c.want {
|
||||
t.Errorf("humanDuration(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- endpoint / persistence -------------------------------------------------
|
||||
|
||||
func TestPassportEndpoint(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
id := newDoc(t, srv)
|
||||
|
||||
do(t, srv, http.MethodPut, "/"+id,
|
||||
`{"content":"{}","content_text":"the first draft","word_count":3}`)
|
||||
|
||||
rec := do(t, srv, http.MethodGet, "/"+id+"/passport", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("passport: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("content-type = %q, want text/html", ct)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Writing passport") {
|
||||
t.Errorf("report body missing heading:\n%s", body)
|
||||
}
|
||||
// Snapshots written through the real insert path must verify.
|
||||
if !strings.Contains(body, "History intact") {
|
||||
t.Errorf("live-written history did not verify:\n%s", body)
|
||||
}
|
||||
|
||||
if rec := do(t, srv, http.MethodGet, "/does-not-exist/passport", ""); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("missing doc: code = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreserveHistoryExemptsFromPruning(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
id := newDoc(t, srv)
|
||||
|
||||
rec := do(t, srv, http.MethodPut, "/"+id, `{"preserve_history":true}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("set preserve_history: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
decodeDoc := func(rec *httptest.ResponseRecorder) db.Document {
|
||||
t.Helper()
|
||||
var doc db.Document
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("decode doc: %v", err)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
if !decodeDoc(rec).PreserveHistory {
|
||||
t.Fatal("preserve_history did not persist")
|
||||
}
|
||||
|
||||
// An ordinary body save must not clear the flag.
|
||||
rec = do(t, srv, http.MethodPut, "/"+id,
|
||||
`{"content":"{}","content_text":"hello there","word_count":2}`)
|
||||
if !decodeDoc(rec).PreserveHistory {
|
||||
t.Error("a normal save cleared preserve_history")
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func (h *Handler) versionRoutes(r chi.Router) {
|
||||
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
|
||||
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
|
||||
r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
|
||||
r.Get("/{id}/passport", h.passport) // authorship report over that history
|
||||
}
|
||||
|
||||
// listVersions returns the document's snapshots, newest first, without the heavy
|
||||
@@ -202,20 +203,73 @@ func (h *Handler) maybeAutoSnapshot(doc db.Document) error {
|
||||
|
||||
// insertVersion writes a snapshot row of the given kind and returns it (without
|
||||
// the heavy content fields, matching the list shape).
|
||||
//
|
||||
// The row is linked into the document's hash chain: it carries the previous
|
||||
// snapshot's hash, and its own hash covers that link plus its content. The hash
|
||||
// can only be computed once the database has assigned created_at, so the insert
|
||||
// and the hash write share a transaction — a snapshot is never visible with a
|
||||
// hash that doesn't cover its own timestamp.
|
||||
func (h *Handler) insertVersion(doc db.Document, kind string) (db.DocumentVersion, error) {
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
return db.DocumentVersion{}, err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
// Chain onto the newest existing snapshot. created_at has second
|
||||
// granularity, so rowid breaks ties in true insertion order; verification
|
||||
// walks the same ordering in reverse.
|
||||
var prevHash string
|
||||
err = tx.QueryRow(
|
||||
`SELECT content_hash FROM document_versions
|
||||
WHERE doc_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`,
|
||||
doc.ID,
|
||||
).Scan(&prevHash)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return db.DocumentVersion{}, err
|
||||
}
|
||||
|
||||
var v db.DocumentVersion
|
||||
err := h.DB.QueryRow(
|
||||
`INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
if err := tx.QueryRow(
|
||||
`INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind, prev_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, doc_id, title, word_count, kind, created_at`,
|
||||
doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind,
|
||||
).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt)
|
||||
return v, err
|
||||
doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind, prevHash,
|
||||
).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
|
||||
return db.DocumentVersion{}, err
|
||||
}
|
||||
|
||||
v.PrevHash = prevHash
|
||||
v.ContentHash = chainHash(prevHash, v.DocID, v.CreatedAt, v.WordCount, doc.ContentText)
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE document_versions SET content_hash = ? WHERE id = ?`, v.ContentHash, v.ID,
|
||||
); err != nil {
|
||||
return db.DocumentVersion{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return db.DocumentVersion{}, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// pruneAutoVersions trims a document's 'auto' snapshots to the newest
|
||||
// maxAutoVersions, leaving 'manual' and 'pre_restore' restore points intact.
|
||||
//
|
||||
// Documents flagged preserve_history are exempt entirely: their history is
|
||||
// authorship evidence, and evidence with the oldest entries dropped is exactly
|
||||
// the part a reader would want — the early, sparse, figuring-it-out edits that
|
||||
// distinguish writing from pasting.
|
||||
func (h *Handler) pruneAutoVersions(docID string) error {
|
||||
var preserve bool
|
||||
if err := h.DB.QueryRow(
|
||||
`SELECT preserve_history FROM documents WHERE id = ?`, docID,
|
||||
).Scan(&preserve); err != nil {
|
||||
return err
|
||||
}
|
||||
if preserve {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := h.DB.Exec(
|
||||
`DELETE FROM document_versions
|
||||
WHERE doc_id = ? AND kind = 'auto'
|
||||
|
||||
@@ -41,6 +41,9 @@ export interface Document {
|
||||
word_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// When true, this document's automatic snapshots are never pruned, so its
|
||||
// full writing trail survives as authorship evidence (see the passport).
|
||||
preserve_history: boolean
|
||||
}
|
||||
|
||||
// Fields the editor sends on auto-save. All optional so a rename can send title
|
||||
@@ -51,6 +54,7 @@ export interface DocUpdate {
|
||||
content_text?: string
|
||||
tone?: string
|
||||
word_count?: number
|
||||
preserve_history?: boolean
|
||||
}
|
||||
|
||||
// One sense of a word from the offline dictionary.
|
||||
@@ -219,6 +223,11 @@ export const api = {
|
||||
// the given format. A one-click "download all my writing" safety net.
|
||||
exportAllUrl: (format: ExportFormat) => `/api/docs/export-all?format=${format}`,
|
||||
|
||||
// Download URL for the writing passport: a standalone HTML report of how this
|
||||
// document was written (timeline, growth, sessions), for showing someone who
|
||||
// questions its authorship. Print to PDF from the browser to hand it over.
|
||||
passportUrl: (id: string) => `/api/docs/${id}/passport`,
|
||||
|
||||
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
|
||||
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
|
||||
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
|
||||
|
||||
@@ -42,6 +42,8 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
const [selected, setSelected] = useState<DocumentVersion | null>(null)
|
||||
const [preview, setPreview] = useState<DocumentVersion | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// Null until the document loads; the passport controls stay inert until then.
|
||||
const [preserve, setPreserve] = useState<boolean | null>(null)
|
||||
const panelRef = useFocusTrap<HTMLElement>()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -57,6 +59,25 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// The keep-full-history flag lives on the document, not on its snapshots.
|
||||
useEffect(() => {
|
||||
void api
|
||||
.getDoc(docId)
|
||||
.then((d) => setPreserve(d.preserve_history))
|
||||
.catch(() => setPreserve(null))
|
||||
}, [docId])
|
||||
|
||||
const togglePreserve = useCallback(async () => {
|
||||
if (preserve === null) return
|
||||
const next = !preserve
|
||||
setPreserve(next) // optimistic; revert if the save fails
|
||||
try {
|
||||
await api.updateDoc(docId, { preserve_history: next })
|
||||
} catch {
|
||||
setPreserve(!next)
|
||||
}
|
||||
}, [docId, preserve])
|
||||
|
||||
// Escape closes the drawer.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -231,6 +252,51 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Writing passport: turn this history into something you can show
|
||||
someone who doubts you wrote this yourself. */}
|
||||
<div
|
||||
className="shrink-0 px-4 py-3"
|
||||
style={{ borderTop: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<a
|
||||
href={api.passportUrl(docId)}
|
||||
download
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-full py-2.5 text-sm font-extrabold"
|
||||
style={{
|
||||
border: '1.5px solid var(--color-accent)',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
📜 写作证明 · Writing passport
|
||||
</a>
|
||||
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||
A report showing how this draft grew, session by session.
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="mt-3 flex cursor-pointer items-start gap-2 text-[11px]"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preserve ?? false}
|
||||
disabled={preserve === null}
|
||||
onChange={togglePreserve}
|
||||
className="mt-0.5 shrink-0"
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||
保留完整历史 · Keep full history
|
||||
</span>
|
||||
<br />
|
||||
Never delete old snapshots of this document, so the record stays complete.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user