dnote/pkg/cli/cmd/sync/merge.go
2025-10-31 23:38:06 -07:00

201 lines
5.1 KiB
Go

/* Copyright 2025 Dnote Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sync
import (
"database/sql"
"fmt"
"strings"
"github.com/dnote/dnote/pkg/cli/client"
"github.com/dnote/dnote/pkg/cli/database"
"github.com/dnote/dnote/pkg/cli/utils"
"github.com/dnote/dnote/pkg/cli/utils/diff"
"github.com/pkg/errors"
)
const (
modeNormal = iota
modeRemote
modeLocal
)
const (
conflictLabelLocal = "<<<<<<< Local\n"
conflictLabelServer = ">>>>>>> Server\n"
conflictLabelDivide = "=======\n"
)
func sanitize(s string) string {
var textBuilder strings.Builder
textBuilder.WriteString(s)
if !strings.HasSuffix(s, "\n") {
textBuilder.WriteString("\n")
}
return textBuilder.String()
}
// reportBodyConflict returns a conflict report of the local and the remote version
// of a body
func reportBodyConflict(localBody, remoteBody string) string {
diffs := diff.Do(localBody, remoteBody)
var ret strings.Builder
mode := modeNormal
maxIdx := len(diffs) - 1
for idx, d := range diffs {
if d.Type == diff.DiffEqual {
if mode != modeNormal {
mode = modeNormal
ret.WriteString(conflictLabelServer)
}
ret.WriteString(d.Text)
}
// within the conflict area, append a linebreak to the text if it is missing one
// to make sure conflict labels are separated by new lines
sanitized := sanitize(d.Text)
if d.Type == diff.DiffDelete {
if mode == modeNormal {
mode = modeLocal
ret.WriteString(conflictLabelLocal)
}
ret.WriteString(sanitized)
}
if d.Type == diff.DiffInsert {
if mode == modeLocal {
mode = modeRemote
ret.WriteString(conflictLabelDivide)
}
ret.WriteString(sanitized)
if idx == maxIdx {
ret.WriteString(conflictLabelServer)
}
}
}
return ret.String()
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}
func reportBookConflict(tx *database.DB, body, localBookUUID, serverBookUUID string) (string, error) {
var builder strings.Builder
var localBookName, serverBookName string
if err := tx.QueryRow("SELECT label FROM books WHERE uuid = ?", localBookUUID).Scan(&localBookName); err != nil {
return "", errors.Wrapf(err, "getting book label for %s", localBookUUID)
}
if err := tx.QueryRow("SELECT label FROM books WHERE uuid = ?", serverBookUUID).Scan(&serverBookName); err != nil {
return "", errors.Wrapf(err, "getting book label for %s", serverBookUUID)
}
builder.WriteString(conflictLabelLocal)
builder.WriteString(fmt.Sprintf("Moved to the book %s\n", localBookName))
builder.WriteString(conflictLabelDivide)
builder.WriteString(fmt.Sprintf("Moved to the book %s\n", serverBookName))
builder.WriteString(conflictLabelServer)
builder.WriteString("\n")
builder.WriteString(body)
return builder.String(), nil
}
func getConflictsBookUUID(tx *database.DB) (string, error) {
var ret string
err := tx.QueryRow("SELECT uuid FROM books WHERE label = ?", "conflicts").Scan(&ret)
if err == sql.ErrNoRows {
// Create a conflicts book
ret, err = utils.GenerateUUID()
if err != nil {
return "", err
}
b := database.NewBook(ret, "conflicts", 0, false, true)
err = b.Insert(tx)
if err != nil {
tx.Rollback()
return "", errors.Wrap(err, "creating the conflicts book")
}
} else if err != nil {
return "", errors.Wrap(err, "getting uuid for conflicts book")
}
return ret, nil
}
// noteMergeReport holds the result of a field-by-field merge of two copies of notes
type noteMergeReport struct {
body string
bookUUID string
editedOn int64
}
// mergeNoteFields performs a field-by-field merge between the local and the server copy. It returns a merge report
// between the local and the server copy of the note.
func mergeNoteFields(tx *database.DB, localNote database.Note, serverNote client.SyncFragNote) (*noteMergeReport, error) {
if !localNote.Dirty {
return &noteMergeReport{
body: serverNote.Body,
bookUUID: serverNote.BookUUID,
editedOn: serverNote.EditedOn,
}, nil
}
body := reportBodyConflict(localNote.Body, serverNote.Body)
var bookUUID string
if serverNote.BookUUID != localNote.BookUUID {
b, err := reportBookConflict(tx, body, localNote.BookUUID, serverNote.BookUUID)
if err != nil {
return nil, errors.Wrapf(err, "reporting book conflict for note %s", localNote.UUID)
}
body = b
conflictsBookUUID, err := getConflictsBookUUID(tx)
if err != nil {
return nil, errors.Wrap(err, "getting the conflicts book uuid")
}
bookUUID = conflictsBookUUID
} else {
bookUUID = serverNote.BookUUID
}
ret := noteMergeReport{
body: body,
bookUUID: bookUUID,
editedOn: maxInt64(localNote.EditedOn, serverNote.EditedOn),
}
return &ret, nil
}