mirror of
https://github.com/dnote/dnote
synced 2026-03-14 22:45:50 +01:00
201 lines
5.1 KiB
Go
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 ¬eMergeReport{
|
|
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
|
|
}
|