diff --git a/internal/sync/cfi_converter.go b/internal/sync/cfi_converter.go new file mode 100644 index 0000000..e85d0bc --- /dev/null +++ b/internal/sync/cfi_converter.go @@ -0,0 +1,1254 @@ +package sync + +import ( + "archive/zip" + "encoding/xml" + "fmt" + "io" + "log" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "unicode/utf8" + + "golang.org/x/net/html" +) + +type CFIConverter struct { + epubPath string + cache *spineCache +} + +type spineItem struct { + href string + mediaType string +} + +type spineCache struct { + items []spineItem + opfDir string + docCache map[string]*html.Node +} + +func NewCFIConverter(epubPath string) *CFIConverter { + return &CFIConverter{epubPath: epubPath} +} + +func (c *CFIConverter) loadSpine() (*spineCache, error) { + if c.cache != nil { + return c.cache, nil + } + + r, err := zip.OpenReader(c.epubPath) + if err != nil { + return nil, fmt.Errorf("open epub: %w", err) + } + defer r.Close() + + containerData, err := readZipFile(&r.Reader, "META-INF/container.xml") + if err != nil { + return nil, fmt.Errorf("read container.xml: %w", err) + } + + opfPath, err := extractOPFPath(containerData) + if err != nil { + return nil, fmt.Errorf("extract opf path: %w", err) + } + + opfData, err := readZipFile(&r.Reader, opfPath) + if err != nil { + return nil, fmt.Errorf("read opf: %w", err) + } + + items, err := parseOPFSpine(opfData) + if err != nil { + return nil, fmt.Errorf("parse opf spine: %w", err) + } + + opfDir := path.Dir(opfPath) + if opfDir == "." { + opfDir = "" + } + + c.cache = &spineCache{ + items: items, + opfDir: opfDir, + docCache: make(map[string]*html.Node), + } + return c.cache, nil +} + +func (c *CFIConverter) getContentDoc(fragmentIndex int) (*html.Node, string, error) { + spine, err := c.loadSpine() + if err != nil { + return nil, "", err + } + + spineIndex := fragmentIndex - 1 + if spineIndex < 0 || spineIndex >= len(spine.items) { + return nil, "", fmt.Errorf("fragment index %d out of range (spine has %d items)", fragmentIndex, len(spine.items)) + } + + item := spine.items[spineIndex] + href := item.href + + if cached, ok := spine.docCache[href]; ok { + return cached, href, nil + } + + r, err := zip.OpenReader(c.epubPath) + if err != nil { + return nil, "", fmt.Errorf("open epub: %w", err) + } + defer r.Close() + + zipPath := resolveZipPath(spine.opfDir, href) + data, err := readZipFile(&r.Reader, zipPath) + if err != nil { + decoded, _ := url.QueryUnescape(zipPath) + if decoded != zipPath { + data, err = readZipFile(&r.Reader, decoded) + } + if err != nil { + return nil, "", fmt.Errorf("read content doc %s: %w", href, err) + } + } + + doc, err := html.Parse(strings.NewReader(preprocessXHTML(string(data)))) + if err != nil { + return nil, "", fmt.Errorf("parse html: %w", err) + } + + spine.docCache[href] = doc + return doc, href, nil +} + +type CREXPointer struct { + FragmentIndex int + ElementPath []pathStep + CharOffset int +} + +type pathStep struct { + tag string + index int + isText bool +} + +type CREFragmentID struct { + SpineIndex int + Anchor string +} + +var creFragmentIDRe = regexp.MustCompile(`^#_doc_fragment_(\d+)(?:[_ ](.*))?$`) + +func ParseCREFragmentID(s string) (*CREFragmentID, error) { + m := creFragmentIDRe.FindStringSubmatch(s) + if m == nil { + return nil, fmt.Errorf("not a CREngine fragment ID: %s", s) + } + idx, err := strconv.Atoi(m[1]) + if err != nil { + return nil, fmt.Errorf("parse fragment spine index: %w", err) + } + return &CREFragmentID{ + SpineIndex: idx, + Anchor: strings.TrimSpace(m[2]), + }, nil +} + +var creXPointerRe = regexp.MustCompile(`^/body/DocFragment\[(\d+)\](.*)`) + +func ParseCREXPointer(xp string) (*CREXPointer, error) { + m := creXPointerRe.FindStringSubmatch(xp) + if m == nil { + return nil, fmt.Errorf("not a CREngine XPointer: %s", xp) + } + + fragIdx, err := strconv.Atoi(m[1]) + if err != nil { + return nil, fmt.Errorf("parse fragment index: %w", err) + } + + result := &CREXPointer{ + FragmentIndex: fragIdx, + CharOffset: 0, + } + + rest := m[2] + if rest == "" { + return result, nil + } + + if strings.HasPrefix(rest, "/body") { + rest = strings.TrimPrefix(rest, "/body") + if rest == "" { + return result, nil + } + } + rest = strings.TrimPrefix(rest, "/") + + if rest == "" { + return result, nil + } + + parts := strings.Split(rest, "/") + for _, part := range parts { + if part == "" { + continue + } + if strings.HasPrefix(part, "text()") { + rest := part[6:] + offset := 0 + if rest != "" { + if idx := strings.Index(rest, "."); idx >= 0 { + offset, _ = strconv.Atoi(rest[idx+1:]) + } + } + result.CharOffset = offset + continue + } + + tag, idx := parseElementPart(part) + result.ElementPath = append(result.ElementPath, pathStep{tag: tag, index: idx}) + } + + return result, nil +} + +func parseElementPart(part string) (string, int) { + idxStr := "" + if i := strings.Index(part, "["); i >= 0 { + idxStr = part[i+1:] + part = part[:i] + if strings.HasSuffix(idxStr, "]") { + idxStr = idxStr[:len(idxStr)-1] + } + } + idx := 1 + if idxStr != "" { + if v, err := strconv.Atoi(idxStr); err == nil { + idx = v + } + } + return part, idx +} + +type ConversionResult struct { + EPUBCFI string + Href string + Percentage float64 + Precision string +} + +func (c *CFIConverter) ConvertCREToStandard(xpointer string, storedPercentage float64, contextText string) (*ConversionResult, error) { + if IsCREFragmentID(xpointer) { + return c.convertFragmentID(xpointer, storedPercentage) + } + + xp, err := ParseCREXPointer(xpointer) + if err != nil { + return nil, err + } + + spine, err := c.loadSpine() + if err != nil { + return &ConversionResult{ + Percentage: storedPercentage, + Precision: "percentage", + }, nil + } + + doc, href, docErr := c.getContentDoc(xp.FragmentIndex) + if docErr != nil { + return &ConversionResult{ + Percentage: storedPercentage, + Precision: "percentage", + }, nil + } + body := findBody(doc) + if body == nil { + return &ConversionResult{ + Percentage: storedPercentage, + Precision: "percentage", + }, nil + } + + if contextText != "" { + result := c.convertByTextSearch(body, xp, spine, href, storedPercentage, contextText) + if result != nil { + return result, nil + } + } + + return c.convertByPercentageOffset(body, xp, spine, href, storedPercentage) +} + +func (c *CFIConverter) convertFragmentID(s string, storedPercentage float64) (*ConversionResult, error) { + frag, err := ParseCREFragmentID(s) + if err != nil { + return &ConversionResult{ + Percentage: storedPercentage, + Precision: "percentage", + }, nil + } + + spine, err := c.loadSpine() + if err != nil { + return &ConversionResult{ + Percentage: storedPercentage, + Precision: "percentage", + }, nil + } + + if frag.SpineIndex < 0 || frag.SpineIndex >= len(spine.items) { + return &ConversionResult{ + Percentage: storedPercentage, + Precision: "percentage", + }, nil + } + + href := spine.items[frag.SpineIndex].href + if frag.Anchor != "" { + idHref := href + "#" + frag.Anchor + return &ConversionResult{ + Href: idHref, + Percentage: storedPercentage, + Precision: "element", + }, nil + } + + return &ConversionResult{ + Href: href, + Percentage: storedPercentage, + Precision: "section", + }, nil +} + +func (c *CFIConverter) convertByTextSearch(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult { + normalizedCtx := normalizeWhitespace(contextText) + if normalizedCtx == "" { + return nil + } + + match, matchOffset := findTextInNode(body, normalizedCtx) + if match == nil { + log.Printf("Bookhoard: text search no match for %q in %s", normalizedCtx, href) + return nil + } + + spineIndex := xp.FragmentIndex - 1 + cfi, err := buildCFI(spineIndex, match, matchOffset) + if err != nil || cfi == "" { + log.Printf("Bookhoard: text search found match but buildCFI failed: %v", err) + return nil + } + + log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", normalizedCtx, cfi) + return &ConversionResult{ + EPUBCFI: cfi, + Href: href, + Percentage: storedPercentage, + Precision: "exact", + } +} + +func findTextInNode(root *html.Node, searchText string) (*html.Node, int) { + normalizedSearch := normalizeWhitespace(searchText) + if normalizedSearch == "" { + return nil, 0 + } + + words := strings.Fields(normalizedSearch) + quoted := make([]string, len(words)) + for i, w := range words { + quoted[i] = regexp.QuoteMeta(w) + } + pattern := strings.Join(quoted, `\s+`) + re, err := regexp.Compile(pattern) + if err != nil { + return nil, 0 + } + + var found *html.Node + foundRuneOffset := 0 + + var walk func(*html.Node) bool + walk = func(n *html.Node) bool { + if n.Type == html.TextNode { + loc := re.FindStringIndex(n.Data) + if loc != nil { + found = n + foundRuneOffset = utf8.RuneCountInString(n.Data[:loc[0]]) + return true + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if walk(c) { + return true + } + } + return false + } + + walk(root) + return found, foundRuneOffset +} + +func normalizeWhitespace(s string) string { + s = strings.Join(strings.Fields(s), " ") + return strings.TrimSpace(s) +} + +func (c *CFIConverter) convertByPercentageOffset(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64) (*ConversionResult, error) { + totalBookChars := 0 + charCounts := make([]int, len(spine.items)) + for i := range spine.items { + doc, _, docErr := c.getContentDoc(i + 1) + if docErr != nil { + continue + } + b := findBody(doc) + if b != nil { + charCounts[i] = countTextChars(b) + totalBookChars += charCounts[i] + } + } + + if totalBookChars <= 0 { + return &ConversionResult{ + Percentage: storedPercentage, + Precision: "percentage", + }, nil + } + + bookWideOffset := int(storedPercentage * float64(totalBookChars)) + if bookWideOffset >= totalBookChars { + bookWideOffset = totalBookChars - 1 + } + + charsBefore := 0 + for i := 0; i < xp.FragmentIndex-1 && i < len(spine.items); i++ { + charsBefore += charCounts[i] + } + + localOffset := bookWideOffset - charsBefore + totalChars := countTextChars(body) + if localOffset < 0 { + localOffset = 0 + } + if localOffset >= totalChars { + localOffset = totalChars - 1 + } + + target, foundOffset := findNodeAtCharOffset(body, localOffset) + + if target != nil { + spineIndex := xp.FragmentIndex - 1 + cfi, err := buildCFI(spineIndex, target, localOffset-foundOffset) + if err == nil && cfi != "" { + return &ConversionResult{ + EPUBCFI: cfi, + Href: href, + Percentage: storedPercentage, + Precision: "exact", + }, nil + } + } + + idHref := findNearestIDHref(body, target, href) + if idHref != "" { + return &ConversionResult{ + Href: idHref, + Percentage: storedPercentage, + Precision: "element", + }, nil + } + + return &ConversionResult{ + Href: href, + Percentage: storedPercentage, + Precision: "section", + }, nil +} + +func findNodeAtCharOffset(root *html.Node, targetOffset int) (*html.Node, int) { + currentOffset := 0 + var found *html.Node + + var walk func(*html.Node) bool + walk = func(n *html.Node) bool { + if n.Type == html.TextNode { + textLen := utf8.RuneCountInString(n.Data) + if currentOffset+textLen > targetOffset { + found = n + return true + } + currentOffset += textLen + return false + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if walk(c) { + return true + } + } + return false + } + + walk(root) + return found, currentOffset +} + +func (c *CFIConverter) computePercentage(xp *CREXPointer, target *html.Node, remainingOffset int) float64 { + spine, err := c.loadSpine() + if err != nil { + return -1 + } + + charOffset := 0 + for i := 1; i < xp.FragmentIndex; i++ { + doc, _, err := c.getContentDoc(i) + if err != nil { + continue + } + body := findBody(doc) + if body != nil { + charOffset += countTextChars(body) + } + } + + charOffset += countTextCharsBefore(target) + charOffset += remainingOffset + + totalChars := int64(0) + for i := range spine.items { + doc, _, err := c.getContentDoc(i + 1) + if err != nil { + continue + } + body := findBody(doc) + if body != nil { + totalChars += int64(countTextChars(body)) + } + } + + if totalChars <= 0 { + return -1 + } + return float64(charOffset) / float64(totalChars) +} + +type indexedNode struct { + node *html.Node + textChunk []*html.Node + virtual string + isNull bool +} + +func (n indexedNode) isElement() bool { + return n.node != nil && n.node.Type == html.ElementNode +} + +func (n indexedNode) isTextChunk() bool { + return len(n.textChunk) > 0 +} + +func indexChildNodes(parent *html.Node) []indexedNode { + var children []*html.Node + for c := parent.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.TextNode || c.Type == html.ElementNode { + children = append(children, c) + } + } + + var nodes []indexedNode + for _, child := range children { + if len(nodes) == 0 { + if child.Type == html.TextNode { + nodes = append(nodes, indexedNode{textChunk: []*html.Node{child}}) + } else { + nodes = append(nodes, indexedNode{node: child}) + } + continue + } + + last := nodes[len(nodes)-1] + if child.Type == html.TextNode { + if last.isTextChunk() { + nodes[len(nodes)-1].textChunk = append(last.textChunk, child) + } else { + nodes = append(nodes, indexedNode{textChunk: []*html.Node{child}}) + } + } else { + if last.isElement() { + nodes = append(nodes, indexedNode{isNull: true}, indexedNode{node: child}) + } else { + nodes = append(nodes, indexedNode{node: child}) + } + } + } + + if len(nodes) > 0 && nodes[0].isElement() { + nodes = append([]indexedNode{{virtual: "first"}}, nodes...) + } + if len(nodes) > 0 && nodes[len(nodes)-1].isElement() { + nodes = append(nodes, indexedNode{virtual: "last"}) + } + nodes = append([]indexedNode{{virtual: "before"}}, nodes...) + nodes = append(nodes, indexedNode{virtual: "after"}) + + return nodes +} + +func findTextChunkIndex(parent *html.Node, textNode *html.Node) (int, int) { + indexed := indexChildNodes(parent) + for i, node := range indexed { + if node.isTextChunk() { + for j, tn := range node.textChunk { + if tn == textNode { + chunkOffset := 0 + for k := 0; k < j; k++ { + chunkOffset += utf8.RuneCountInString(node.textChunk[k].Data) + } + return i, chunkOffset + } + } + } + } + return -1, 0 +} + +func findElementCFIIndex(parent *html.Node, element *html.Node) int { + indexed := indexChildNodes(parent) + for i, node := range indexed { + if node.isElement() && node.node == element { + return i + } + } + return -1 +} + +func escapeCFI(s string) string { + r := strings.NewReplacer( + "^", "^^", + "[", "^[", + "]", "^]", + "(", "^(", + ")", "^)", + ",", "^,", + ";", "^;", + "=", "^=", + ) + return r.Replace(s) +} + +func buildCFI(spineIndex int, textNode *html.Node, charOffset int) (string, error) { + if textNode == nil || textNode.Type != html.TextNode { + return "", fmt.Errorf("buildCFI requires a text node") + } + + parent := textNode.Parent + if parent == nil { + return "", fmt.Errorf("text node has no parent") + } + + chunkIdx, chunkOffset := findTextChunkIndex(parent, textNode) + if chunkIdx == -1 { + return "", fmt.Errorf("text node not found in parent's indexed children") + } + + totalOffset := chunkOffset + charOffset + + var parts []string + parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, totalOffset)) + + current := parent + for current != nil { + if current.Type != html.ElementNode { + current = current.Parent + continue + } + + p := current.Parent + if p == nil { + break + } + + elemIdx := findElementCFIIndex(p, current) + if elemIdx == -1 { + return "", fmt.Errorf("element not found in parent's indexed children") + } + + id := getAttr(current, "id") + step := fmt.Sprintf("/%d", elemIdx) + if id != "" { + step = fmt.Sprintf("/%d[%s]", elemIdx, escapeCFI(id)) + } + parts = append([]string{step}, parts...) + + body := findBodyFromNode(p) + if p == body || body == nil { + break + } + current = p + } + + parts = append([]string{"/4"}, parts...) + + spineStep := (spineIndex + 1) * 2 + localPath := strings.Join(parts, "") + + return fmt.Sprintf("epubcfi(/6/%d!%s)", spineStep, localPath), nil +} + +func findNearestIDHref(body *html.Node, target *html.Node, baseHref string) string { + if target == nil { + return "" + } + + for current := target; current != nil; current = current.Parent { + if current.Type != html.ElementNode { + continue + } + if id := getAttr(current, "id"); id != "" { + if strings.Contains(baseHref, "#") { + return baseHref + id + } + return baseHref + "#" + id + } + bodyNode := findBodyFromNode(current) + if current == bodyNode || bodyNode == nil { + break + } + } + + return baseHref +} + +func findHTMLElement(doc *html.Node) *html.Node { + var find func(*html.Node) *html.Node + find = func(n *html.Node) *html.Node { + if n.Type == html.ElementNode && strings.EqualFold(n.Data, "html") { + return n + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if found := find(c); found != nil { + return found + } + } + return nil + } + return find(doc) +} + +func findBody(doc *html.Node) *html.Node { + var find func(*html.Node) *html.Node + find = func(n *html.Node) *html.Node { + if n.Type == html.ElementNode && strings.EqualFold(n.Data, "body") { + return n + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if found := find(c); found != nil { + return found + } + } + return nil + } + return find(doc) +} + +func findBodyFromNode(n *html.Node) *html.Node { + for n != nil { + if n.Type == html.ElementNode && strings.EqualFold(n.Data, "body") { + return n + } + n = n.Parent + } + return nil +} + +func getAttr(n *html.Node, key string) string { + for _, a := range n.Attr { + if strings.EqualFold(a.Key, key) { + return a.Val + } + } + return "" +} + +func countTextChars(n *html.Node) int { + count := 0 + if n.Type == html.TextNode { + count += utf8.RuneCountInString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + count += countTextChars(c) + } + return count +} + +func countTextCharsBefore(target *html.Node) int { + count := 0 + for c := target; c != nil; c = c.PrevSibling { + count += countTextChars(c) + } + if target.Parent != nil { + count += countTextCharsBefore(target.Parent) + } + return count +} + +func resolveZipPath(opfDir, href string) string { + if opfDir == "" { + return href + } + return path.Join(opfDir, href) +} + +func readZipFile(zr *zip.Reader, name string) ([]byte, error) { + for _, f := range zr.File { + if f.Name == name { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) + } + } + return nil, fmt.Errorf("file not found: %s", name) +} + +type opfContainer struct { + XMLName xml.Name `xml:"container"` + RootFiles []opfRoot `xml:"rootfiles>rootfile"` +} + +type opfRoot struct { + FullPath string `xml:"full-path,attr"` +} + +func extractOPFPath(data []byte) (string, error) { + var c opfContainer + if err := xml.Unmarshal(data, &c); err != nil { + return "", err + } + for _, rf := range c.RootFiles { + if rf.FullPath != "" { + return rf.FullPath, nil + } + } + return "", fmt.Errorf("no rootfile found in container.xml") +} + +type xmlPackage struct { + XMLName xml.Name `xml:"package"` + Spine xmlSpine `xml:"spine"` + Manifest xmlManifest `xml:"manifest"` +} + +type xmlSpine struct { + ItemRefs []xmlItemRef `xml:"itemref"` +} + +type xmlItemRef struct { + IDRef string `xml:"idref,attr"` +} + +type xmlManifest struct { + Items []xmlItem `xml:"item"` +} + +type xmlItem struct { + ID string `xml:"id,attr"` + Href string `xml:"href,attr"` + MediaType string `xml:"media-type,attr"` +} + +func parseOPFSpine(data []byte) ([]spineItem, error) { + var pkg xmlPackage + if err := xml.Unmarshal(data, &pkg); err != nil { + return nil, err + } + + manifest := make(map[string]spineItem) + for _, item := range pkg.Manifest.Items { + manifest[item.ID] = spineItem{ + href: item.Href, + mediaType: item.MediaType, + } + } + + var items []spineItem + for _, ref := range pkg.Spine.ItemRefs { + if item, ok := manifest[ref.IDRef]; ok { + items = append(items, item) + } + } + + return items, nil +} + +func IsCREXPointer(s string) bool { + return strings.HasPrefix(s, "/body/DocFragment[") || strings.HasPrefix(s, "#_doc_fragment_") +} + +func IsCREFragmentID(s string) bool { + return strings.HasPrefix(s, "#_doc_fragment_") +} + +func IsStandardEPUBCFI(s string) bool { + return strings.HasPrefix(s, "epubcfi(") && strings.Contains(s, ")") +} + +var voidElements = map[string]bool{ + "area": true, "base": true, "br": true, "col": true, "embed": true, + "hr": true, "img": true, "input": true, "link": true, "meta": true, + "param": true, "source": true, "track": true, "wbr": true, +} + +var selfClosingRe = regexp.MustCompile(`<([a-zA-Z][a-zA-Z0-9]*)([^>]*?)/\s*>`) + +func preprocessXHTML(input string) string { + return selfClosingRe.ReplaceAllStringFunc(input, func(match string) string { + sub := selfClosingRe.FindStringSubmatch(match) + if len(sub) < 3 { + return match + } + tag := strings.ToLower(sub[1]) + if voidElements[tag] { + return match + } + return fmt.Sprintf("<%s%s>", sub[1], sub[2], sub[1]) + }) +} + +type cfiStep struct { + Index int + ID string + Offset int + HasOffset bool +} + +var cfiStepRe = regexp.MustCompile(`/(\d+)(?:\[([^\]]*)\])?(?::(\d+))?`) + +func parseCFISteps(s string) []cfiStep { + var steps []cfiStep + matches := cfiStepRe.FindAllStringSubmatch(s, -1) + for _, m := range matches { + idx, _ := strconv.Atoi(m[1]) + step := cfiStep{Index: idx} + if m[2] != "" { + step.ID = m[2] + } + if m[3] != "" { + step.Offset, _ = strconv.Atoi(m[3]) + step.HasOffset = true + } + steps = append(steps, step) + } + return steps +} + +func parseEPUBCFI(cfi string) (spineIndex int, localSteps []cfiStep, err error) { + inner := strings.TrimPrefix(cfi, "epubcfi(") + inner = strings.TrimSuffix(inner, ")") + + parts := strings.SplitN(inner, "!", 2) + if len(parts) < 2 { + return 0, nil, fmt.Errorf("no step indirection in CFI") + } + + spineSteps := parseCFISteps(parts[0]) + if len(spineSteps) < 2 { + return 0, nil, fmt.Errorf("no spine step in CFI") + } + spineIndex = spineSteps[len(spineSteps)-1].Index/2 - 1 + + localPart := parts[1] + if strings.Contains(localPart, ",") { + commaParts := strings.SplitN(localPart, ",", 3) + if len(commaParts) < 2 { + return 0, nil, fmt.Errorf("invalid range CFI") + } + parentSteps := parseCFISteps(commaParts[0]) + startSteps := parseCFISteps(commaParts[1]) + localSteps = append(parentSteps, startSteps...) + } else { + localSteps = parseCFISteps(localPart) + } + + if len(localSteps) < 1 { + return 0, nil, fmt.Errorf("no local steps in CFI") + } + return spineIndex, localSteps, nil +} + +func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error) { + current := findHTMLElement(doc) + if current == nil { + return nil, 0, fmt.Errorf("no element found in document") + } + for i := 0; i < len(steps)-1; i++ { + step := steps[i] + indexed := indexChildNodes(current) + if step.Index >= len(indexed) { + return nil, 0, fmt.Errorf("step %d index %d out of range (max %d)", i, step.Index, len(indexed)-1) + } + entry := indexed[step.Index] + if entry.isElement() { + current = entry.node + } else if entry.isTextChunk() { + current = entry.textChunk[0] + } else { + return nil, 0, fmt.Errorf("step %d hit virtual/null node", i) + } + } + + lastStep := steps[len(steps)-1] + indexed := indexChildNodes(current) + if lastStep.Index >= len(indexed) { + return nil, 0, fmt.Errorf("last step index %d out of range (max %d)", lastStep.Index, len(indexed)-1) + } + entry := indexed[lastStep.Index] + + if entry.isTextChunk() { + textOffset := 0 + if lastStep.HasOffset { + textOffset = lastStep.Offset + } + + var targetNode *html.Node + remainingOffset := textOffset + for _, tn := range entry.textChunk { + textLen := utf8.RuneCountInString(tn.Data) + if remainingOffset < textLen || (remainingOffset == textLen && targetNode == nil) { + targetNode = tn + break + } + remainingOffset -= textLen + targetNode = tn + } + if targetNode == nil && len(entry.textChunk) > 0 { + targetNode = entry.textChunk[len(entry.textChunk)-1] + } + + parent := targetNode.Parent + totalOffset := 0 + for c := parent.FirstChild; c != nil; c = c.NextSibling { + if c == targetNode { + break + } + totalOffset += countTextChars(c) + } + totalOffset += remainingOffset + + return targetNode, totalOffset, nil + } + + if entry.isElement() { + return entry.node, 0, nil + } + + return nil, 0, fmt.Errorf("last step hit virtual/null node") +} + +func buildCREXPointer(spineIndex int, node *html.Node, charOffset int) (string, error) { + current := node + if current.Type == html.TextNode { + current = current.Parent + } + if current == nil || current.Type != html.ElementNode { + return "", fmt.Errorf("no element node to build XPointer from") + } + + var parts []string + for current != nil { + if current.Type != html.ElementNode { + current = current.Parent + continue + } + + parent := current.Parent + if parent == nil { + break + } + + if strings.EqualFold(current.Data, "body") { + break + } + + tagCount := 0 + for c := parent.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.ElementNode && strings.EqualFold(c.Data, current.Data) { + tagCount++ + if c == current { + break + } + } + } + + parts = append([]string{fmt.Sprintf("/%s[%d]", current.Data, tagCount)}, parts...) + + if parent.Type == html.ElementNode && strings.EqualFold(parent.Data, "body") { + break + } + current = parent + } + + fragIndex := spineIndex + 1 + xpointer := fmt.Sprintf("/body/DocFragment[%d]/body%s", fragIndex, strings.Join(parts, "")) + + if charOffset > 0 || (node.Type == html.TextNode) { + xpointer += fmt.Sprintf("/text().%d", charOffset) + } + + return xpointer, nil +} + +type ReverseConversionResult struct { + XPointer string + Precision string + Percentage float64 +} + +func (c *CFIConverter) ConvertStandardToCRE(epubcfi string, storedPercentage float64, contextText string) (*ReverseConversionResult, error) { + spineIndex, localSteps, err := parseEPUBCFI(epubcfi) + if err != nil { + log.Printf("Bookhoard: CFI→CRE parse failed: %v", err) + return c.reverseByTextSearch(epubcfi, storedPercentage, contextText) + } + + doc, _, docErr := c.getContentDoc(spineIndex + 1) + if docErr != nil { + log.Printf("Bookhoard: CFI→CRE content doc load failed: %v", docErr) + return c.reverseByTextSearch(epubcfi, storedPercentage, contextText) + } + + textNode, charOffset, resolveErr := resolveCFIToNode(doc, localSteps) + if resolveErr != nil { + log.Printf("Bookhoard: CFI→CRE resolution failed: %v", resolveErr) + return c.reverseByTextSearch(epubcfi, storedPercentage, contextText) + } + + xpointer, buildErr := buildCREXPointer(spineIndex, textNode, charOffset) + if buildErr != nil { + log.Printf("Bookhoard: CFI→CRE build failed: %v", buildErr) + return c.reverseByTextSearch(epubcfi, storedPercentage, contextText) + } + + log.Printf("Bookhoard: CFI→CRE converted %s → %s", epubcfi, xpointer) + return &ReverseConversionResult{ + XPointer: xpointer, + Precision: "exact", + Percentage: storedPercentage, + }, nil +} + +func (c *CFIConverter) reverseByTextSearch(epubcfi string, storedPercentage float64, contextText string) (*ReverseConversionResult, error) { + normalizedCtx := normalizeWhitespace(contextText) + if normalizedCtx == "" { + return &ReverseConversionResult{ + Precision: "percentage", + Percentage: storedPercentage, + }, nil + } + + spineIndex, _, parseErr := parseEPUBCFI(epubcfi) + if parseErr != nil { + spine, err := c.loadSpine() + if err != nil { + return &ReverseConversionResult{ + Precision: "percentage", + Percentage: storedPercentage, + }, nil + } + + bookWideOffset := int(storedPercentage * float64(totalBookChars(c, spine))) + spineIndex = 0 + charsBefore := 0 + for i := range spine.items { + doc, _, docErr := c.getContentDoc(i + 1) + if docErr != nil { + continue + } + body := findBody(doc) + if body == nil { + continue + } + cc := countTextChars(body) + if charsBefore+cc > bookWideOffset { + spineIndex = i + break + } + charsBefore += cc + } + } + + doc, _, docErr := c.getContentDoc(spineIndex + 1) + if docErr != nil { + return &ReverseConversionResult{ + Precision: "percentage", + Percentage: storedPercentage, + }, nil + } + + body := findBody(doc) + if body == nil { + return &ReverseConversionResult{ + Precision: "percentage", + Percentage: storedPercentage, + }, nil + } + + match, matchOffset := findTextInNode(body, normalizedCtx) + if match == nil { + log.Printf("Bookhoard: CFI→CRE text search no match for %q", normalizedCtx) + return &ReverseConversionResult{ + Precision: "percentage", + Percentage: storedPercentage, + }, nil + } + + parent := match.Parent + totalOffset := matchOffset + for c := parent.FirstChild; c != nil; c = c.NextSibling { + if c == match { + break + } + totalOffset += countTextChars(c) + } + + xpointer, buildErr := buildCREXPointer(spineIndex, match, totalOffset) + if buildErr != nil { + log.Printf("Bookhoard: CFI→CRE text search build failed: %v", buildErr) + return &ReverseConversionResult{ + Precision: "percentage", + Percentage: storedPercentage, + }, nil + } + + log.Printf("Bookhoard: CFI→CRE text search matched → %s", xpointer) + return &ReverseConversionResult{ + XPointer: xpointer, + Precision: "exact", + Percentage: storedPercentage, + }, nil +} + +func totalBookChars(c *CFIConverter, spine *spineCache) int { + total := 0 + for i := range spine.items { + doc, _, err := c.getContentDoc(i + 1) + if err != nil { + continue + } + body := findBody(doc) + if body != nil { + total += countTextChars(body) + } + } + return total +} diff --git a/internal/sync/cfi_converter_test.go b/internal/sync/cfi_converter_test.go new file mode 100644 index 0000000..26889fe --- /dev/null +++ b/internal/sync/cfi_converter_test.go @@ -0,0 +1,353 @@ +package sync + +import ( + "testing" +) + +func TestParseCREXPointer(t *testing.T) { + tests := []struct { + input string + wantFrag int + wantPath int + wantChar int + }{ + {"/body/DocFragment[0]/body/div[4]/p[38]/text().541", 0, 2, 541}, + {"/body/DocFragment[2]/body/div/p[5]/text()[2].16", 2, 2, 16}, + {"/body/DocFragment[1]/body", 1, 0, 0}, + {"/body/DocFragment[5]/body/div[3]/p[28]", 5, 2, 0}, + } + + for _, tt := range tests { + xp, err := ParseCREXPointer(tt.input) + if err != nil { + t.Errorf("ParseCREXPointer(%q) error: %v", tt.input, err) + continue + } + if xp.FragmentIndex != tt.wantFrag { + t.Errorf("FragmentIndex = %d, want %d", xp.FragmentIndex, tt.wantFrag) + } + if len(xp.ElementPath) != tt.wantPath { + t.Errorf("len(ElementPath) = %d, want %d (got %+v)", len(xp.ElementPath), tt.wantPath, xp.ElementPath) + } + if xp.CharOffset != tt.wantChar { + t.Errorf("CharOffset = %d, want %d", xp.CharOffset, tt.wantChar) + } + } +} + +func TestParseCREXPointerInvalid(t *testing.T) { + _, err := ParseCREXPointer("epubcfi(/6/4!/4/2/1:0)") + if err == nil { + t.Error("expected error for standard epubcfi") + } + _, err = ParseCREXPointer("") + if err == nil { + t.Error("expected error for empty string") + } +} + +func TestIsCREXPointer(t *testing.T) { + if !IsCREXPointer("/body/DocFragment[0]/body/div/p") { + t.Error("should recognize CRE XPointer") + } + if !IsCREXPointer("#_doc_fragment_5_ link2HCH0002") { + t.Error("should recognize CRE fragment ID") + } + if IsCREXPointer("epubcfi(/6/4!/4/2/1:0)") { + t.Error("should not recognize standard epubcfi as CRE") + } +} + +func TestIsCREFragmentID(t *testing.T) { + if !IsCREFragmentID("#_doc_fragment_5_ link2HCH0002") { + t.Error("should recognize fragment ID") + } + if IsCREFragmentID("/body/DocFragment[2]/body") { + t.Error("should not recognize XPointer as fragment ID") + } +} + +func TestParseCREFragmentID(t *testing.T) { + tests := []struct { + input string + wantSpine int + wantAnchor string + }{ + {"#_doc_fragment_5_ link2HCH0002", 5, "link2HCH0002"}, + {"#_doc_fragment_0_", 0, ""}, + {"#_doc_fragment_12_someid123", 12, "someid123"}, + } + + for _, tt := range tests { + frag, err := ParseCREFragmentID(tt.input) + if err != nil { + t.Errorf("ParseCREFragmentID(%q) error: %v", tt.input, err) + continue + } + if frag.SpineIndex != tt.wantSpine { + t.Errorf("SpineIndex = %d, want %d", frag.SpineIndex, tt.wantSpine) + } + if frag.Anchor != tt.wantAnchor { + t.Errorf("Anchor = %q, want %q", frag.Anchor, tt.wantAnchor) + } + } +} + +func TestParseCREFragmentIDInvalid(t *testing.T) { + _, err := ParseCREFragmentID("/body/DocFragment[2]/body") + if err == nil { + t.Error("expected error for XPointer input") + } + _, err = ParseCREFragmentID("#_doc_fragment_") + if err == nil { + t.Error("expected error for missing index") + } +} + +func TestIsStandardEPUBCFI(t *testing.T) { + if !IsStandardEPUBCFI("epubcfi(/6/4!/4/2/1:0)") { + t.Error("should recognize standard epubcfi") + } + if IsStandardEPUBCFI("/body/DocFragment[0]/body") { + t.Error("should not recognize CRE as standard") + } +} + +func TestConvert1984(t *testing.T) { + epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub" + c := NewCFIConverter(epubPath) + + xp := "/body/DocFragment[2]/body/div/p[5]/text().500" + result, err := c.ConvertCREToStandard(xp, 0.01, "") + if err != nil { + t.Fatalf("ConvertCREToStandard error: %v", err) + } + t.Logf("Input: %s", xp) + t.Logf("EPUBCFI: %s", result.EPUBCFI) + t.Logf("Href: %s", result.Href) + t.Logf("Precision: %s", result.Precision) + t.Logf("Percentage: %.4f", result.Percentage) + + if result.Precision == "percentage" { + t.Error("expected better than percentage precision") + } +} + +func TestConvertCrimeAndPunishmentFragmentID(t *testing.T) { + epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub" + c := NewCFIConverter(epubPath) + + xp := "#_doc_fragment_5_ link2HCH0002" + result, err := c.ConvertCREToStandard(xp, 0.0303, "") + if err != nil { + t.Fatalf("ConvertCREToStandard error: %v", err) + } + t.Logf("Input: %s", xp) + t.Logf("EPUBCFI: %s", result.EPUBCFI) + t.Logf("Href: %s", result.Href) + t.Logf("Precision: %s", result.Precision) + t.Logf("Percentage: %.4f", result.Percentage) + + if result.Precision == "percentage" { + t.Error("expected better than percentage precision") + } + if result.Href == "" { + t.Error("expected non-empty href") + } + if result.Precision != "element" { + t.Errorf("expected element precision, got %s", result.Precision) + } +} + +func TestConvertHessBook(t *testing.T) { + epubPath := "/home/nymusicman/Code/bookhoard/William L Hess/Crushing the Great Serpent_ Did God (42)/Crushing the Great Serpent_ Did - William L Hess.epub" + c := NewCFIConverter(epubPath) + + xp := "/body/DocFragment[5]/body/div[3]/p[28]/text().500" + result, err := c.ConvertCREToStandard(xp, 0.08, "") + if err != nil { + t.Fatalf("ConvertCREToStandard error: %v", err) + } + t.Logf("Input: %s", xp) + t.Logf("EPUBCFI: %s", result.EPUBCFI) + t.Logf("Href: %s", result.Href) + t.Logf("Precision: %s", result.Precision) + t.Logf("Percentage: %.4f", result.Percentage) + + if result.Precision == "percentage" { + t.Error("expected better than percentage precision") + } +} + +func TestConvertCPByTextSearch(t *testing.T) { + epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub" + c := NewCFIConverter(epubPath) + + xp := "/body/DocFragment[6]/body/div/p[47]/text().1" + contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l" + result, err := c.ConvertCREToStandard(xp, 0.0579, contextText) + if err != nil { + t.Fatalf("ConvertCREToStandard error: %v", err) + } + t.Logf("Input: %s", xp) + t.Logf("ContextText: %s", contextText) + t.Logf("EPUBCFI: %s", result.EPUBCFI) + t.Logf("Href: %s", result.Href) + t.Logf("Precision: %s", result.Precision) + t.Logf("Percentage: %.4f", result.Percentage) + + if result.Precision != "exact" { + t.Errorf("expected exact precision, got %s", result.Precision) + } + if result.EPUBCFI == "" { + t.Error("expected non-empty EPUBCFI") + } +} + +func TestParseEPUBCFI(t *testing.T) { + tests := []struct { + input string + wantSpine int + wantSteps int + }{ + {"epubcfi(/6/12!/4/2/90/1:7)", 5, 4}, + {"epubcfi(/6/4!/4/2/1:0)", 1, 3}, + {"epubcfi(/6/2!/4)", 0, 1}, + } + + for _, tt := range tests { + spineIndex, steps, err := parseEPUBCFI(tt.input) + if err != nil { + t.Errorf("parseEPUBCFI(%q) error: %v", tt.input, err) + continue + } + if spineIndex != tt.wantSpine { + t.Errorf("spineIndex = %d, want %d", spineIndex, tt.wantSpine) + } + if len(steps) != tt.wantSteps { + t.Errorf("len(steps) = %d, want %d", len(steps), tt.wantSteps) + } + } +} + +func TestParseEPUBCFIRange(t *testing.T) { + spineIndex, steps, err := parseEPUBCFI("epubcfi(/6/40!/4,/24/20,/40/5:61)") + if err != nil { + t.Fatalf("parseEPUBCFI range error: %v", err) + } + if spineIndex != 19 { + t.Errorf("spineIndex = %d, want 19", spineIndex) + } + t.Logf("Range CFI steps: %d", len(steps)) + for i, s := range steps { + t.Logf(" step %d: index=%d offset=%d hasOffset=%v", i, s.Index, s.Offset, s.HasOffset) + } +} + +func TestParseEPUBCFIInvalid(t *testing.T) { + _, _, err := parseEPUBCFI("not-a-cfi") + if err == nil { + t.Error("expected error for invalid CFI") + } + _, _, err = parseEPUBCFI("epubcfi(/6/12)") + if err == nil { + t.Error("expected error for CFI without indirection") + } +} + +func TestRoundTrip1984(t *testing.T) { + epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub" + c := NewCFIConverter(epubPath) + + originalXP := "/body/DocFragment[2]/body/div/p[5]/text().500" + forward, err := c.ConvertCREToStandard(originalXP, 0.01, "") + if err != nil { + t.Fatalf("forward conversion error: %v", err) + } + if forward.EPUBCFI == "" { + t.Fatal("forward conversion produced empty epubcfi") + } + t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI) + + reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, "") + if err != nil { + t.Fatalf("reverse conversion error: %v", err) + } + if reverse.XPointer == "" { + t.Fatal("reverse conversion produced empty XPointer") + } + t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer) + t.Logf("Reverse precision: %s", reverse.Precision) + + if reverse.Precision != "exact" { + t.Errorf("expected exact precision, got %s", reverse.Precision) + } +} + +func TestRoundTripCP(t *testing.T) { + epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub" + c := NewCFIConverter(epubPath) + + originalXP := "/body/DocFragment[6]/body/div/p[47]/text().2399" + contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l" + forward, err := c.ConvertCREToStandard(originalXP, 0.0579, contextText) + if err != nil { + t.Fatalf("forward conversion error: %v", err) + } + if forward.EPUBCFI == "" { + t.Fatal("forward conversion produced empty epubcfi") + } + t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI) + + reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, contextText) + if err != nil { + t.Fatalf("reverse conversion error: %v", err) + } + if reverse.XPointer == "" { + t.Fatal("reverse conversion produced empty XPointer") + } + t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer) + t.Logf("Reverse precision: %s", reverse.Precision) + + if reverse.Precision != "exact" { + t.Errorf("expected exact precision, got %s", reverse.Precision) + } +} + +func TestReverseTextSearchFallback(t *testing.T) { + epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub" + c := NewCFIConverter(epubPath) + + contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l" + reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.0579, contextText) + if err != nil { + t.Fatalf("reverse conversion error: %v", err) + } + t.Logf("Text search fallback XPointer: %s", reverse.XPointer) + t.Logf("Precision: %s", reverse.Precision) + + if reverse.Precision != "exact" { + t.Errorf("expected exact precision from text search, got %s", reverse.Precision) + } + if reverse.XPointer == "" { + t.Error("expected non-empty XPointer from text search") + } +} + +func TestReversePercentageFallback(t *testing.T) { + epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub" + c := NewCFIConverter(epubPath) + + reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.5, "") + if err != nil { + t.Fatalf("reverse conversion error: %v", err) + } + t.Logf("Percentage fallback precision: %s", reverse.Precision) + + if reverse.Precision != "percentage" { + t.Errorf("expected percentage precision, got %s with XPointer %s", reverse.Precision, reverse.XPointer) + } + if reverse.XPointer != "" { + t.Error("expected empty XPointer for percentage fallback") + } +} diff --git a/internal/sync/cfi_debug_test.go b/internal/sync/cfi_debug_test.go new file mode 100644 index 0000000..eef798f --- /dev/null +++ b/internal/sync/cfi_debug_test.go @@ -0,0 +1,37 @@ +package sync + +import "testing" + +func TestDebugHess(t *testing.T) { + epubPath := "/home/nymusicman/Code/bookhoard/William L Hess/Crushing the Great Serpent_ Did God (42)/Crushing the Great Serpent_ Did - William L Hess.epub" + c := NewCFIConverter(epubPath) + + xp, err := ParseCREXPointer("/body/DocFragment[5]/body/div[3]/p[28]/text().500") + if err != nil { + t.Fatal(err) + } + t.Logf("FragmentIndex: %d, CharOffset: %d", xp.FragmentIndex, xp.CharOffset) + + doc, href, err := c.getContentDoc(xp.FragmentIndex) + if err != nil { + t.Fatalf("getContentDoc error: %v", err) + } + t.Logf("href: %s", href) + + body := findBody(doc) + if body == nil { + t.Fatal("no body") + } + + totalChars := countTextChars(body) + t.Logf("total chars in doc: %d", totalChars) + + target, foundOffset := findNodeAtCharOffset(body, 500) + if target == nil { + t.Fatal("target is nil") + } + t.Logf("foundOffset: %d target: <%s>", foundOffset, target.Data) + + cfi, err := buildCFI(5, target, 0) + t.Logf("cfi: %q err: %v", cfi, err) +}