refactor(gui): разделить app.go на binding-файлы по доменам, вынести sync apply
- app.go (1810→280 строк): только App struct, startup, DTOs, helpers
- bindings_{nodes,files,notes,actions,worklog,activity,sync,settings}.go
- sync_apply.go: все applyRemote* методы
- i18n: internal/i18n (Go, embed JSON) + frontend/src/lib/i18n (JS)
- core/sync/safe_path.go: SafeVaultPath
- scripts/check-i18n.sh: проверка хардкода кириллицы и bidi-символов
- build.sh: NVM loading, set -e
Все сборки (CLI, server, gui, frontend), go vet, go test проходят.
This commit is contained in:
parent
390d451977
commit
3089d777a8
10
build.sh
10
build.sh
|
|
@ -1,4 +1,14 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Load NVM for Node.js
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.config/nvm}"
|
||||
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
||||
. "$NVM_DIR/nvm.sh"
|
||||
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
. "$HOME/.nvm/nvm.sh"
|
||||
fi
|
||||
|
||||
cd frontend && npm run build && cd ..
|
||||
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
|
||||
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,52 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
|
||||
list, err := a.actions.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ActionDTO, len(list))
|
||||
for i := range list {
|
||||
data := list[i].Command
|
||||
if list[i].URL != "" {
|
||||
data = list[i].URL
|
||||
}
|
||||
result[i] = ActionDTO{
|
||||
ID: list[i].ID,
|
||||
NodeID: list[i].NodeID,
|
||||
Title: list[i].Title,
|
||||
Type: list[i].Kind,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error) {
|
||||
rec, err := a.actions.Create(nodeID, kind, title, data, "", data, nil, kind == "run_command" || kind == "run_script", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, actionPayload(rec))
|
||||
return &ActionDTO{
|
||||
ID: rec.ID,
|
||||
NodeID: rec.NodeID,
|
||||
Title: rec.Title,
|
||||
Type: rec.Kind,
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteAction(id string) error {
|
||||
_ = a.sync.RecordOp(syncsvc.EntityAction, id, syncsvc.OpDelete, nil)
|
||||
return a.actions.Delete(id)
|
||||
}
|
||||
|
||||
func (a *App) RunAction(id string) error {
|
||||
_, err := a.actions.Run(id)
|
||||
return err
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/i18n"
|
||||
)
|
||||
|
||||
func (a *App) ListSections() []SectionDTO {
|
||||
return []SectionDTO{
|
||||
{ID: "today", Label: i18n.TF("ru", "nav.today")},
|
||||
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")},
|
||||
{ID: "activity", Label: i18n.TF("ru", "nav.activity")},
|
||||
{ID: "clients", Label: i18n.TF("ru", "nav.clients")},
|
||||
{ID: "projects", Label: i18n.TF("ru", "nav.projects")},
|
||||
{ID: "recipes", Label: i18n.TF("ru", "nav.recipes")},
|
||||
{ID: "documents", Label: i18n.TF("ru", "nav.documents")},
|
||||
{ID: "archive", Label: i18n.TF("ru", "nav.archive")},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
|
||||
aeByParent, err := a.activity.ListTodayEventsByParent()
|
||||
if err != nil {
|
||||
aeByParent = nil
|
||||
}
|
||||
todayNodes, _ := a.nodes.ListTodayNodes()
|
||||
|
||||
type rawEvent struct {
|
||||
NodeID string
|
||||
EventType string
|
||||
TargetType string
|
||||
TargetID string
|
||||
TargetPath string
|
||||
Title string
|
||||
CreatedAt string
|
||||
}
|
||||
type caseInfo struct {
|
||||
Node nodes.Node
|
||||
Events []rawEvent
|
||||
}
|
||||
caseMap := make(map[string]*caseInfo)
|
||||
|
||||
ensureCase := func(caseID string) *caseInfo {
|
||||
if ci, ok := caseMap[caseID]; ok {
|
||||
return ci
|
||||
}
|
||||
ci := &caseInfo{Events: nil}
|
||||
if n, err := a.nodes.GetActive(caseID); err == nil {
|
||||
ci.Node = *n
|
||||
}
|
||||
caseMap[caseID] = ci
|
||||
return ci
|
||||
}
|
||||
|
||||
for pid, events := range aeByParent {
|
||||
ci := ensureCase(pid)
|
||||
for _, e := range events {
|
||||
ci.Events = append(ci.Events, rawEvent{
|
||||
NodeID: e.NodeID,
|
||||
EventType: e.EventType,
|
||||
TargetType: e.TargetType,
|
||||
TargetID: e.TargetID,
|
||||
TargetPath: e.TargetPath,
|
||||
Title: e.Title,
|
||||
CreatedAt: e.CreatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, n := range todayNodes {
|
||||
_ = ensureCase(n.ID)
|
||||
if ci := caseMap[n.ID]; ci.Node.ID == "" {
|
||||
ci.Node = n
|
||||
}
|
||||
}
|
||||
|
||||
var groups []TodayGroupDTO
|
||||
var flatEvents []EventDTO
|
||||
summary := SummaryDTO{}
|
||||
|
||||
for _, ci := range caseMap {
|
||||
if ci.Node.ID == "" {
|
||||
continue
|
||||
}
|
||||
summary.ChangedCases++
|
||||
|
||||
dtoEvents := make([]EventDTO, 0, len(ci.Events))
|
||||
for _, re := range ci.Events {
|
||||
dtoEvents = append(dtoEvents, EventDTO{
|
||||
ID: ci.Node.ID + "/" + re.NodeID + "/" + re.CreatedAt,
|
||||
NodeID: re.NodeID,
|
||||
EventType: re.EventType,
|
||||
TargetType: re.TargetType,
|
||||
TargetID: re.TargetID,
|
||||
TargetPath: re.TargetPath,
|
||||
Title: re.Title,
|
||||
CreatedAt: re.CreatedAt,
|
||||
})
|
||||
switch re.EventType {
|
||||
case activity.TypeNoteCreated, activity.TypeNoteUpdated:
|
||||
summary.Notes++
|
||||
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed, activity.TypeFileCopied, activity.TypeFileMoved:
|
||||
summary.Files++
|
||||
}
|
||||
}
|
||||
|
||||
last := ci.Node.UpdatedAt.Format(time.RFC3339)
|
||||
for _, e := range dtoEvents {
|
||||
if e.CreatedAt > last {
|
||||
last = e.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
groups = append(groups, TodayGroupDTO{
|
||||
NodeID: ci.Node.ID,
|
||||
NodeTitle: ci.Node.Title,
|
||||
NodeKind: ci.Node.Type,
|
||||
Section: ci.Node.Section,
|
||||
LastActivityAt: last,
|
||||
Events: dtoEvents,
|
||||
})
|
||||
flatEvents = append(flatEvents, dtoEvents...)
|
||||
}
|
||||
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
return groups[i].LastActivityAt > groups[j].LastActivityAt
|
||||
})
|
||||
sort.Slice(flatEvents, func(i, j int) bool {
|
||||
return flatEvents[i].CreatedAt > flatEvents[j].CreatedAt
|
||||
})
|
||||
|
||||
return &TodayDashboardDTO{
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Summary: summary,
|
||||
Groups: groups,
|
||||
Events: flatEvents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
|
||||
events, err := a.activity.ListRecent(limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = toEventDTO(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error) {
|
||||
events, err := a.activity.ListByNode(nodeID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = toEventDTO(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CountActivityByNode(nodeID string) (int, error) {
|
||||
return a.activity.CountByNode(nodeID)
|
||||
}
|
||||
|
||||
var _ = syncsvc.EntityNode
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
||||
records, err := a.files.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]FileDTO, len(records))
|
||||
for i := range records {
|
||||
rec := &records[i]
|
||||
result[i] = FileDTO{
|
||||
ID: rec.ID,
|
||||
NodeID: rec.NodeID,
|
||||
Name: rec.Filename,
|
||||
Path: rec.Path,
|
||||
Size: rec.Size,
|
||||
Mime: rec.MIME,
|
||||
IsDir: rec.MIME == "inode/directory",
|
||||
Missing: rec.Missing,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
|
||||
children, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]FileTreeItemDTO, 0, len(children))
|
||||
for i := range children {
|
||||
if children[i].Type != nodes.TypeFolder && children[i].Type != nodes.TypeFile {
|
||||
continue
|
||||
}
|
||||
item := FileTreeItemDTO{
|
||||
ID: children[i].ID,
|
||||
Name: children[i].Title,
|
||||
Type: children[i].Type,
|
||||
}
|
||||
if children[i].Type == nodes.TypeFolder {
|
||||
kids, _ := a.nodes.ListChildren(children[i].ID, false)
|
||||
item.HasKids = len(kids) > 0
|
||||
} else if children[i].Type == nodes.TypeFile {
|
||||
records, _ := a.files.ListByNode(children[i].ID)
|
||||
if len(records) > 0 {
|
||||
item.FileID = records[0].ID
|
||||
item.Size = records[0].Size
|
||||
item.Mime = records[0].MIME
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteFileOrFolder(nodeID string) error {
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err == nil {
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
evType := activity.TypeFileDeleted
|
||||
targetType := activity.TargetFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
evType = activity.TypeFolderDeleted
|
||||
targetType = activity.TargetFolder
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
|
||||
syncEntity := syncsvc.EntityFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
syncEntity = syncsvc.EntityFolder
|
||||
}
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpDelete, nil)
|
||||
}
|
||||
return a.files.DeleteNodeAndChildren(nodeID)
|
||||
}
|
||||
|
||||
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
||||
node, err := a.files.CreateEmptyFile(parentID, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
||||
node, err := a.files.Duplicate(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err2 := a.nodes.GetActive(nodeID)
|
||||
pid := ""
|
||||
if err2 == nil && n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) ValidateName(name string) error {
|
||||
return files.ValidateName(name)
|
||||
}
|
||||
|
||||
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
||||
return a.files.PreviewImport(sourcePath)
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
|
||||
list, err := a.nodes.ListRoots(false, section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeDTOs(list), nil
|
||||
}
|
||||
|
||||
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
|
||||
list, err := a.nodes.ListChildren(parentID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeDTOs(list), nil
|
||||
}
|
||||
|
||||
func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(n)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
|
||||
if section == "today" || section == "inbox" {
|
||||
return nil, fmt.Errorf("cannot create node with section %q", section)
|
||||
}
|
||||
n, err := a.nodes.Create(parentID, nodeType, title, section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, nodePayload(n))
|
||||
dto := toNodeDTO(n)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteNode(id string) error {
|
||||
return a.nodes.SoftDelete(id)
|
||||
}
|
||||
|
||||
func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldTitle := n.Title
|
||||
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
|
||||
return err
|
||||
}
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
evType := activity.TypeFileRenamed
|
||||
targetType := activity.TargetFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
evType = activity.TypeFolderRenamed
|
||||
targetType = activity.TargetFolder
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
|
||||
syncEntity := syncsvc.EntityFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
syncEntity = syncsvc.EntityFolder
|
||||
}
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
"title": newTitle,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||
destChildren, err := a.nodes.ListChildren(newParentID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range destChildren {
|
||||
if destChildren[i].Title == node.Title {
|
||||
newName := a.files.UniqueTitleCopy(newParentID, node.Title)
|
||||
if err := a.nodes.UpdateTitle(nodeID, newName); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := a.nodes.Move(nodeID, newParentID, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
pid := ""
|
||||
if node.ParentID != nil {
|
||||
pid = *node.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]interface{}{
|
||||
"parent_id": newParentID,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
|
||||
children, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []NodeDTO
|
||||
for i := range children {
|
||||
if children[i].Type == nodes.TypeNote {
|
||||
result = append(result, toNodeDTO(&children[i]))
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
||||
node, fileRec, err := a.notes.Create(parentID, title, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, ""))
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) ReadNote(noteID string) (string, error) {
|
||||
return a.notes.Read(noteID)
|
||||
}
|
||||
|
||||
func (a *App) SaveNote(noteID, content string) error {
|
||||
if err := a.notes.Save(noteID, content); err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := a.nodes.GetActive(noteID); err == nil {
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
"node_id": noteID,
|
||||
"content": content,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/i18n"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
type TemplateDTO struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
func (a *App) ListTemplates() []TemplateDTO {
|
||||
templates := a.plugins.Templates()
|
||||
out := make([]TemplateDTO, 0, len(templates))
|
||||
for _, t := range templates {
|
||||
out = append(out, TemplateDTO{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Icon: t.Icon,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) FromTemplate(parentID, nodeType, title, section, template string) (*NodeDTO, error) {
|
||||
var tmpl *plugins.TemplateDefinition
|
||||
for _, t := range a.plugins.Templates() {
|
||||
if t.Name == template {
|
||||
tmpl = &t
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmpl == nil {
|
||||
return nil, nil
|
||||
}
|
||||
root, err := a.nodes.Create(parentID, tmpl.RootType, title, section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var createTree func(parentID string, nodes []plugins.TreeNode) error
|
||||
createTree = func(parentID string, nodes []plugins.TreeNode) error {
|
||||
for _, tn := range nodes {
|
||||
child, err := a.nodes.Create(parentID, tn.Type, tn.Title, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tn.Children) > 0 {
|
||||
if err := createTree(child.ID, tn.Children); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := createTree(root.ID, tmpl.Tree); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(root)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
||||
if query == "" {
|
||||
return []SearchResultDTO{}, nil
|
||||
}
|
||||
results, err := a.search.Search(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SearchResultDTO, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = SearchResultDTO{
|
||||
NodeID: r.NodeID,
|
||||
Title: r.Title,
|
||||
Snippet: r.Snippet,
|
||||
Type: r.Type,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) PickFile() (string, error) {
|
||||
return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: i18n.TF("ru", "file.pickSingle"),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) PickFiles() ([]string, error) {
|
||||
return wailsruntime.OpenMultipleFilesDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: i18n.TF("ru", "file.pickMultiple"),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) PickDirectory() (string, error) {
|
||||
return wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
Title: i18n.TF("ru", "file.pickDirectory"),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) OpenFile(fileID string) error {
|
||||
return a.files.Open(fileID)
|
||||
}
|
||||
|
||||
func (a *App) ReadFileText(fileID string) (string, error) {
|
||||
return a.files.ReadText(fileID)
|
||||
}
|
||||
|
||||
func (a *App) GetFileBase64(fileID string) (string, error) {
|
||||
return a.files.ReadBase64(fileID)
|
||||
}
|
||||
|
||||
func (a *App) OpenFolder(nodeID string) error {
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Join(a.vault, "spaces", n.Slug)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
dir = a.vault
|
||||
}
|
||||
cmd := exec.Command("xdg-open", dir)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (a *App) VerstakVersion() string {
|
||||
return "verstak-gui/v2"
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
type SyncStatusDTO struct {
|
||||
Configured bool `json:"configured"`
|
||||
ServerURL string `json:"serverUrl"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
Connected bool `json:"connected"`
|
||||
Revoked bool `json:"revoked"`
|
||||
TokenStored bool `json:"tokenStored"`
|
||||
UnpushedOps int `json:"unpushedOps"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
SyncInterval int `json:"syncInterval"`
|
||||
}
|
||||
|
||||
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||
serverURL, apiKey, _, lastSyncAt, err := a.sync.GetState()
|
||||
if err != nil {
|
||||
return &SyncStatusDTO{}, nil
|
||||
}
|
||||
cfg, _ := config.Load(a.vault)
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
dto := &SyncStatusDTO{
|
||||
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
|
||||
ServerURL: serverURL,
|
||||
LastSyncAt: lastSyncAt,
|
||||
UnpushedOps: 0,
|
||||
TokenStored: deviceToken != "",
|
||||
}
|
||||
if cfg != nil {
|
||||
dto.DeviceID = cfg.Sync.DeviceID
|
||||
dto.SyncInterval = cfg.Sync.SyncInterval
|
||||
}
|
||||
unpushed, _ := a.sync.GetUnpushedOps()
|
||||
dto.UnpushedOps = len(unpushed)
|
||||
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
if cfg != nil {
|
||||
client.DeviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
if info, err := client.GetMe(); err == nil {
|
||||
dto.DeviceName = info.DeviceName
|
||||
dto.DeviceID = info.DeviceID
|
||||
dto.Connected = true
|
||||
if info.RevokedAt != "" {
|
||||
dto.Revoked = true
|
||||
dto.Connected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-gui/v2")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pair: %w", err)
|
||||
}
|
||||
if err := config.SaveDeviceToken(a.vault, deviceToken); err != nil {
|
||||
return fmt.Errorf("save token: %w", err)
|
||||
}
|
||||
if err := a.sync.SetState(serverURL, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
cfg.Sync.ServerURL = serverURL
|
||||
cfg.Sync.DeviceID = deviceID
|
||||
cfg.Sync.APIKey = ""
|
||||
return config.Save(a.vault, cfg)
|
||||
}
|
||||
|
||||
func (a *App) SyncDisconnect() error {
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
_ = client.RevokeCurrent()
|
||||
}
|
||||
config.RemoveDeviceToken(a.vault)
|
||||
cfg.Sync.ServerURL = ""
|
||||
cfg.Sync.DeviceID = ""
|
||||
cfg.Sync.APIKey = ""
|
||||
if err := config.Save(a.vault, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.sync.SetState("", "")
|
||||
}
|
||||
|
||||
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
return client.TestAuth(serverURL, username, password)
|
||||
}
|
||||
|
||||
func (a *App) SyncSetInterval(minutes int) error {
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
if cfg.Sync.ServerURL == "" {
|
||||
sURL, _, _, _, _ := a.sync.GetState()
|
||||
if sURL != "" {
|
||||
cfg.Sync.ServerURL = sURL
|
||||
}
|
||||
}
|
||||
if cfg.Sync.DeviceID == "" {
|
||||
cfg.Sync.DeviceID = a.sync.GetDeviceID()
|
||||
}
|
||||
cfg.Sync.SyncInterval = minutes
|
||||
return config.Save(a.vault, cfg)
|
||||
}
|
||||
|
||||
func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
serverURL, apiKey, lastPullSeq, _, err := a.sync.GetState()
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
|
||||
return nil, fmt.Errorf("sync not configured")
|
||||
}
|
||||
|
||||
deviceID := ""
|
||||
if cfg, err := config.Load(a.vault); err == nil {
|
||||
deviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
|
||||
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
|
||||
unpushed, err := a.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ops: %w", err)
|
||||
}
|
||||
for i := range unpushed {
|
||||
unpushed[i].LastSeenServerSeq = lastPullSeq
|
||||
}
|
||||
pushResult := &syncsvc.PushResponse{}
|
||||
if len(unpushed) > 0 {
|
||||
pushResult, err = client.Push(unpushed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: %w", err)
|
||||
}
|
||||
if err := a.sync.MarkPushed(pushResult.Accepted); err != nil {
|
||||
return nil, fmt.Errorf("mark pushed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
pullResult, err := client.Pull(lastPullSeq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pull: %w", err)
|
||||
}
|
||||
|
||||
var applyErrors []string
|
||||
for _, op := range pullResult.Ops {
|
||||
if err := a.applyRemoteOp(op); err != nil {
|
||||
applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err))
|
||||
}
|
||||
_ = a.sync.RecordRemoteOp(op)
|
||||
}
|
||||
if len(pullResult.Ops) > 0 {
|
||||
opIDs := make([]string, len(pullResult.Ops))
|
||||
for i, op := range pullResult.Ops {
|
||||
opIDs[i] = op.OpID
|
||||
}
|
||||
_ = a.sync.MarkApplied(opIDs)
|
||||
}
|
||||
|
||||
if len(pushResult.Conflicts) > 0 {
|
||||
log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts))
|
||||
for _, c := range pushResult.Conflicts {
|
||||
log.Printf("[sync] conflict: op=%v entity=%v/%v",
|
||||
c["op_id"], c["entity_type"], c["entity_id"])
|
||||
}
|
||||
}
|
||||
|
||||
if pullResult.ServerSequence > lastPullSeq {
|
||||
_ = a.sync.SetLastPullSeq(pullResult.ServerSequence)
|
||||
}
|
||||
_ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
result := map[string]interface{}{
|
||||
"pushed": len(pushResult.Accepted),
|
||||
"pulled": len(pullResult.Ops),
|
||||
"serverSequence": pullResult.ServerSequence,
|
||||
}
|
||||
if len(applyErrors) > 0 {
|
||||
result["applyErrors"] = applyErrors
|
||||
}
|
||||
if len(pushResult.Conflicts) > 0 {
|
||||
result["conflicts"] = pushResult.Conflicts
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
||||
list, err := a.worklog.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]WorklogDTO, len(list))
|
||||
for i := range list {
|
||||
mins := 0
|
||||
if list[i].Minutes != nil {
|
||||
mins = *list[i].Minutes
|
||||
}
|
||||
result[i] = WorklogDTO{
|
||||
ID: list[i].ID,
|
||||
NodeID: list[i].NodeID,
|
||||
Summary: list[i].Summary,
|
||||
Minutes: mins,
|
||||
CreatedAt: list[i].CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
|
||||
entry, err := a.worklog.Add(nodeID, summary, "", minutes, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
||||
mins := 0
|
||||
if entry.Minutes != nil {
|
||||
mins = *entry.Minutes
|
||||
}
|
||||
return &WorklogDTO{
|
||||
ID: entry.ID,
|
||||
NodeID: entry.NodeID,
|
||||
Summary: entry.Summary,
|
||||
Minutes: mins,
|
||||
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@ import (
|
|||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
|
|
@ -80,11 +80,11 @@ func main() {
|
|||
}
|
||||
|
||||
err = wails.Run(&options.App{
|
||||
Title: "Верстак",
|
||||
Width: 1280,
|
||||
Height: 800,
|
||||
MinWidth: 800,
|
||||
MinHeight: 600,
|
||||
Title: "Верстак",
|
||||
Width: 1280,
|
||||
Height: 800,
|
||||
MinWidth: 800,
|
||||
MinHeight: 600,
|
||||
BackgroundColour: &options.RGBA{R: 19, G: 19, B: 31, A: 1},
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,467 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
// applyRemoteOp dispatches a remote sync operation to the correct entity handler.
|
||||
func (a *App) applyRemoteOp(op syncsvc.Op) error {
|
||||
switch op.EntityType {
|
||||
case syncsvc.EntityNode:
|
||||
return a.applyRemoteNodeOp(op)
|
||||
case syncsvc.EntityNote:
|
||||
return a.applyRemoteNoteOp(op)
|
||||
case syncsvc.EntityFile, syncsvc.EntityFolder:
|
||||
return a.applyRemoteFileOrFolderOp(op)
|
||||
case syncsvc.EntityAction:
|
||||
return a.applyRemoteActionOp(op)
|
||||
case syncsvc.EntityWorklog:
|
||||
return a.applyRemoteWorklogOp(op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- apply helpers ---
|
||||
|
||||
func (a *App) applyRemoteNodeOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteNodeCreate(op)
|
||||
case syncsvc.OpUpdate:
|
||||
return a.applyRemoteNodeUpdate(op)
|
||||
case syncsvc.OpMove:
|
||||
return a.applyRemoteNodeMove(op)
|
||||
case syncsvc.OpDelete:
|
||||
return a.applyRemoteNodeDelete(op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Section string `json:"section"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal node create: %w", err)
|
||||
}
|
||||
if payload.ID == "" || payload.Type == "" || payload.Title == "" {
|
||||
return fmt.Errorf("incomplete node payload")
|
||||
}
|
||||
|
||||
if _, err := a.nodes.Get(payload.ID); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if payload.CreatedAt == "" {
|
||||
payload.CreatedAt = now
|
||||
}
|
||||
if payload.UpdatedAt == "" {
|
||||
payload.UpdatedAt = now
|
||||
}
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
var section interface{}
|
||||
if payload.Section != "" {
|
||||
section = payload.Section
|
||||
}
|
||||
slug := payload.Slug
|
||||
if slug == "" {
|
||||
slug = nodes.Slugify(payload.Title)
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,section,sort_order,created_at,updated_at,revision,device_id)
|
||||
VALUES (?,?,?,?,?,?,0,?,?,1,NULL)`,
|
||||
payload.ID, parent, payload.Type, payload.Title, slug, section,
|
||||
payload.CreatedAt, payload.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
Title string `json:"title"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal node update: %w", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if payload.UpdatedAt != "" {
|
||||
now = payload.UpdatedAt
|
||||
}
|
||||
if payload.Title != "" {
|
||||
slug := nodes.Slugify(payload.Title)
|
||||
_, err := a.db.Exec(
|
||||
`UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`,
|
||||
payload.Title, slug, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
_, err := a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal node move: %w", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if payload.UpdatedAt != "" {
|
||||
now = payload.UpdatedAt
|
||||
}
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
|
||||
parent, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeDelete(op syncsvc.Op) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := a.db.Exec(
|
||||
`UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
||||
now, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNoteOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteNoteCreate(op)
|
||||
case syncsvc.OpUpdate:
|
||||
return a.applyRemoteNoteUpdate(op)
|
||||
case syncsvc.OpDelete:
|
||||
return a.applyRemoteNodeDelete(op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
FileID string `json:"file_id"`
|
||||
Format string `json:"format"`
|
||||
Content string `json:"content"`
|
||||
Filename string `json:"filename"`
|
||||
Path string `json:"path"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal note create: %w", err)
|
||||
}
|
||||
if payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
||||
slug := nodes.Slugify("remote-note")
|
||||
_, e := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO nodes (id,type,title,slug,created_at,updated_at,revision)
|
||||
VALUES (?,'note','remote-note',?,?,?,1)`,
|
||||
payload.NodeID, slug, now, now)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
dest := filepath.Join(a.vault, payload.Path)
|
||||
if payload.Path == "" {
|
||||
filename := payload.Filename
|
||||
if filename == "" {
|
||||
filename = payload.NodeID[:8] + ".md"
|
||||
}
|
||||
dest = filepath.Join(a.vault, "spaces", filename)
|
||||
payload.Path, _ = filepath.Rel(a.vault, dest)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dest, []byte(payload.Content), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
info, _ := os.Stat(dest)
|
||||
size := int64(0)
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
|
||||
fileID := payload.FileID
|
||||
if fileID == "" {
|
||||
fileID = util.UUID7()
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'text/plain',?,?,0)`,
|
||||
fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
format := payload.Format
|
||||
if format == "" {
|
||||
format = "markdown"
|
||||
}
|
||||
_, err = a.db.Exec(
|
||||
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
payload.NodeID, fileID, format)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Content string `json:"content"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal note update: %w", err)
|
||||
}
|
||||
if payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var filePath, storageMode string
|
||||
err := a.db.QueryRow(
|
||||
`SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id=?`,
|
||||
payload.NodeID).Scan(&filePath, &storageMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("note record not found: %w", err)
|
||||
}
|
||||
|
||||
var abs string
|
||||
if storageMode == "vault" {
|
||||
abs = filepath.Join(a.vault, filePath)
|
||||
} else {
|
||||
abs = filePath
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(payload.Content), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
info, _ := os.Stat(abs)
|
||||
size := int64(0)
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, e := a.db.Exec(
|
||||
`UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`,
|
||||
size, now, filePath, storageMode)
|
||||
return e
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteFileCreate(op)
|
||||
case syncsvc.OpUpdate:
|
||||
return a.applyRemoteNodeUpdate(op)
|
||||
case syncsvc.OpMove:
|
||||
return a.applyRemoteNodeMove(op)
|
||||
case syncsvc.OpDelete:
|
||||
return a.applyRemoteNodeDelete(op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteFileCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Filename string `json:"filename"`
|
||||
Path string `json:"path"`
|
||||
StorageMode string `json:"storage_mode"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
MIME string `json:"mime"`
|
||||
FileID string `json:"file_id"`
|
||||
BlobSHA256 string `json:"blob_sha256"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal file create: %w", err)
|
||||
}
|
||||
if payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
||||
slug := payload.Slug
|
||||
if slug == "" {
|
||||
slug = nodes.Slugify(payload.Title)
|
||||
}
|
||||
ntype := payload.Type
|
||||
if ntype == "" {
|
||||
ntype = "file"
|
||||
}
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
_, e := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,created_at,updated_at,revision)
|
||||
VALUES (?,?,?,?,?,?,?,1)`,
|
||||
payload.NodeID, parent, ntype, payload.Title, slug, now, now)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
if payload.BlobSHA256 != "" && payload.StorageMode == "vault" {
|
||||
blobsDir := syncsvc.BlobDir(a.vault)
|
||||
blobPath := syncsvc.BlobPath(blobsDir, payload.BlobSHA256)
|
||||
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
|
||||
serverURL, apiKey, _, _, _ := a.sync.GetState()
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
cli := syncsvc.NewClient(serverURL, apiKey, "", a.vault)
|
||||
cli.DeviceToken = deviceToken
|
||||
if err := cli.DownloadBlob(payload.BlobSHA256, blobPath); err != nil {
|
||||
log.Printf("[sync] blob download failed for %s: %v", payload.BlobSHA256, err)
|
||||
}
|
||||
}
|
||||
|
||||
dest := filepath.Join(a.vault, payload.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err == nil {
|
||||
input, rErr := os.ReadFile(blobPath)
|
||||
if rErr == nil {
|
||||
_ = os.WriteFile(dest, input, 0o640)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileID := payload.FileID
|
||||
if fileID == "" {
|
||||
fileID = util.UUID7()
|
||||
}
|
||||
storageMode := payload.StorageMode
|
||||
if storageMode == "" {
|
||||
storageMode = "vault"
|
||||
}
|
||||
mime := payload.MIME
|
||||
if mime == "" {
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
|
||||
fileID, payload.NodeID, payload.Filename, payload.Path, storageMode,
|
||||
payload.Size, payload.SHA256, mime, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteActionOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteActionCreate(op)
|
||||
case syncsvc.OpDelete:
|
||||
_, err := a.db.Exec(`DELETE FROM actions WHERE id=?`, op.EntityID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteActionCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
WorkingDir string `json:"working_dir"`
|
||||
URL string `json:"url"`
|
||||
ConfirmRequired bool `json:"confirm_required"`
|
||||
CaptureOutput bool `json:"capture_output"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal action create: %w", err)
|
||||
}
|
||||
if payload.ID == "" || payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,confirm_required,capture_output,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
payload.ID, payload.NodeID, payload.Title, payload.Kind,
|
||||
payload.Command, jsonArgs(payload.Args), payload.WorkingDir, payload.URL,
|
||||
boolToInt(payload.ConfirmRequired), boolToInt(payload.CaptureOutput),
|
||||
payload.CreatedAt, payload.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteWorklogOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteWorklogCreate(op)
|
||||
case syncsvc.OpDelete:
|
||||
_, err := a.db.Exec(`DELETE FROM worklog_entries WHERE id=?`, op.EntityID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteWorklogCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Summary string `json:"summary"`
|
||||
Details string `json:"details"`
|
||||
Minutes int `json:"minutes"`
|
||||
Date string `json:"date"`
|
||||
StartedAt string `json:"started_at"`
|
||||
EndedAt string `json:"ended_at"`
|
||||
Approximate bool `json:"approximate"`
|
||||
Billable bool `json:"billable"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal worklog create: %w", err)
|
||||
}
|
||||
if payload.ID == "" || payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO worklog_entries (id,node_id,started_at,ended_at,date,minutes,approximate,billable,summary,details,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
payload.ID, payload.NodeID, strPtr(payload.StartedAt), strPtr(payload.EndedAt),
|
||||
payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable),
|
||||
payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import ru from './locales/ru.js'
|
||||
import en from './locales/en.js'
|
||||
|
||||
const catalogs = { ru, en }
|
||||
let currentLocale = 'ru'
|
||||
|
||||
export function t(key, params) {
|
||||
const catalog = catalogs[currentLocale]
|
||||
let msg = catalog?.[key]
|
||||
if (msg == null && currentLocale !== 'ru') {
|
||||
msg = catalogs.ru?.[key]
|
||||
}
|
||||
if (msg == null) {
|
||||
msg = key
|
||||
}
|
||||
if (params != null) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
msg = msg.replace(`{${k}}`, String(v))
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
export function setLocale(locale) {
|
||||
if (catalogs[locale]) {
|
||||
currentLocale = locale
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocale() {
|
||||
return currentLocale
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
export default {
|
||||
'nav.today': 'Today',
|
||||
'nav.inbox': 'Inbox',
|
||||
'nav.activity': 'Activity',
|
||||
'nav.clients': 'Clients',
|
||||
'nav.projects': 'Projects',
|
||||
'nav.recipes': 'Recipes',
|
||||
'nav.documents': 'Documents',
|
||||
'nav.archive': 'Archive',
|
||||
|
||||
'tab.overview': 'Overview',
|
||||
'tab.notes': 'Notes',
|
||||
'tab.files': 'Files',
|
||||
'tab.actions': 'Actions',
|
||||
'tab.worklog': 'Work Log',
|
||||
'tab.activity': 'Activity',
|
||||
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.delete': 'Delete',
|
||||
'common.rename': 'Rename',
|
||||
'common.close': 'Close',
|
||||
'common.create': 'Create',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.back': '← Back',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Error:',
|
||||
'common.yes': 'Yes',
|
||||
'common.ok': 'OK',
|
||||
'common.run': 'Run',
|
||||
'common.name': 'Name',
|
||||
'common.settings': 'Settings',
|
||||
|
||||
'welcome.title': 'Verstak',
|
||||
'welcome.selectSection': 'Select a section in the sidebar.',
|
||||
'welcome.addCase': 'Add case',
|
||||
|
||||
'event.noteCreated': 'Note created',
|
||||
'event.noteUpdated': 'Note updated',
|
||||
'event.fileAdded': 'File added',
|
||||
'event.fileDeleted': 'File deleted',
|
||||
'event.fileRenamed': 'File renamed',
|
||||
'event.fileCopied': 'File copied',
|
||||
'event.fileMoved': 'File moved',
|
||||
'event.caseCreated': 'Case created',
|
||||
|
||||
'action.openUrl': 'Open URL',
|
||||
'action.openFile': 'Open file',
|
||||
'action.openFolder': 'Open folder',
|
||||
'action.runCommand': 'Run command',
|
||||
'action.runScript': 'Run script',
|
||||
'action.openTerminal': 'Open terminal',
|
||||
'action.launchApp': 'Launch app',
|
||||
|
||||
'note.add': '+ Add note',
|
||||
'note.noNotes': 'No notes',
|
||||
'note.title': 'Note title',
|
||||
'note.placeholder': 'Start writing...',
|
||||
|
||||
'file.addFile': '+ Add file',
|
||||
'file.addFolder': '+ Add folder',
|
||||
'file.preview': 'Preview',
|
||||
'file.openExternal': 'Open in external program',
|
||||
'file.openFolder': 'Open folder',
|
||||
'file.delete': 'Delete',
|
||||
'file.pickSingle': 'Select file',
|
||||
'file.pickDirectory': 'Select folder',
|
||||
|
||||
'sync.title': 'Sync',
|
||||
'sync.settings': 'Sync settings',
|
||||
'sync.status': 'Status',
|
||||
'sync.server': 'Server',
|
||||
'sync.device': 'Device',
|
||||
'sync.connected': 'Connected',
|
||||
'sync.notConnected': 'Not connected',
|
||||
'sync.disabled': 'Disabled',
|
||||
|
||||
'error.generic': 'An error occurred',
|
||||
'error.invalidCredentials': 'Invalid username or password',
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
export default {
|
||||
'nav.today': 'Сегодня',
|
||||
'nav.inbox': 'Неразобранное',
|
||||
'nav.activity': 'Активность',
|
||||
'nav.clients': 'Клиенты',
|
||||
'nav.projects': 'Проекты',
|
||||
'nav.recipes': 'Рецепты',
|
||||
'nav.documents': 'Документы',
|
||||
'nav.archive': 'Архив',
|
||||
'nav.sections': 'Разделы',
|
||||
'nav.cases': 'Дела',
|
||||
'nav.noCases': 'Нет дел',
|
||||
'nav.sync': 'Синхронизация',
|
||||
'nav.syncSettings': 'Настройки синхронизации',
|
||||
'nav.syncNow': 'Синхронизировать',
|
||||
'nav.selectPrompt': 'Выберите раздел или дело',
|
||||
'nav.brand': 'Верстак',
|
||||
|
||||
'tab.overview': 'Обзор',
|
||||
'tab.notes': 'Заметки',
|
||||
'tab.files': 'Файлы',
|
||||
'tab.actions': 'Действия',
|
||||
'tab.worklog': 'Журнал',
|
||||
'tab.activity': 'Активность',
|
||||
|
||||
'common.save': 'Сохранить',
|
||||
'common.cancel': 'Отмена',
|
||||
'common.delete': 'Удалить',
|
||||
'common.rename': 'Переименовать',
|
||||
'common.close': 'Закрыть',
|
||||
'common.create': 'Создать',
|
||||
'common.confirm': 'Подтверждение',
|
||||
'common.back': '← Назад',
|
||||
'common.loading': 'Загрузка...',
|
||||
'common.error': 'Ошибка:',
|
||||
'common.yes': 'Да',
|
||||
'common.ok': 'OK',
|
||||
'common.copy': 'Копировать',
|
||||
'common.cut': 'Вырезать',
|
||||
'common.paste': 'Вставить',
|
||||
'common.duplicate': 'Дублировать',
|
||||
'common.run': 'Запустить',
|
||||
'common.test': 'Test',
|
||||
'common.testAgain': 'Проверить',
|
||||
'common.connect': 'Подключиться',
|
||||
'common.disconnect': 'Отключиться',
|
||||
'common.settings': 'Настройки',
|
||||
'common.name': 'Название',
|
||||
'common.type': 'Тип',
|
||||
'common.section': 'Раздел',
|
||||
'common.created': 'Создано',
|
||||
'common.empty': 'Нет',
|
||||
'common.newName': 'Новое имя',
|
||||
|
||||
'welcome.title': 'Верстак',
|
||||
'welcome.selectSection': 'Выберите раздел в боковой панели.',
|
||||
'welcome.createCase': 'Или создайте новое дело кнопкой «+».',
|
||||
'welcome.addCase': 'Добавить дело',
|
||||
|
||||
'event.noteCreated': 'Заметка создана',
|
||||
'event.noteUpdated': 'Заметка изменена',
|
||||
'event.fileAdded': 'Файл добавлен',
|
||||
'event.fileDeleted': 'Файл удалён',
|
||||
'event.fileRenamed': 'Файл переименован',
|
||||
'event.fileCopied': 'Файл скопирован',
|
||||
'event.fileMoved': 'Файл перемещён',
|
||||
'event.folderAdded': 'Папка добавлена',
|
||||
'event.folderDeleted': 'Папка удалена',
|
||||
'event.folderRenamed': 'Папка переименована',
|
||||
'event.caseCreated': 'Дело создано',
|
||||
'event.caseUpdated': 'Дело изменено',
|
||||
|
||||
'kind.project': 'Проект',
|
||||
'kind.client': 'Клиент',
|
||||
'kind.document': 'Документ',
|
||||
'kind.recipe': 'Рецепт',
|
||||
'kind.archive': 'Архив',
|
||||
'kind.case': 'Дело',
|
||||
|
||||
'action.openUrl': 'Открыть URL',
|
||||
'action.openFile': 'Открыть файл',
|
||||
'action.openFolder': 'Открыть папку',
|
||||
'action.runCommand': 'Запустить команду',
|
||||
'action.runScript': 'Запустить скрипт',
|
||||
'action.openTerminal': 'Открыть терминал',
|
||||
'action.launchApp': 'Запустить приложение',
|
||||
'action.addAction': '+ Добавить действие',
|
||||
'action.newAction': 'Новое действие',
|
||||
'action.noActions': 'Действий пока нет',
|
||||
'action.run': 'Запустить',
|
||||
'action.dataUrl': 'URL',
|
||||
'action.dataPath': 'Путь',
|
||||
'action.dataCommand': 'Команда',
|
||||
'action.urlPlaceholder': 'https://example.com',
|
||||
'action.pathPlaceholder': '/path/to/file',
|
||||
'action.commandPlaceholder': 'команда',
|
||||
'action.namePlaceholder': 'Например: Открыть сайт',
|
||||
|
||||
'note.add': '+ Добавить заметку',
|
||||
'note.new': 'Новая заметка',
|
||||
'note.title': 'Название заметки',
|
||||
'note.noNotes': 'Нет заметок',
|
||||
'note.createFirst': 'Создайте первую заметку для этого дела.',
|
||||
'note.placeholder': 'Начните писать...',
|
||||
'note.unsavedTitle': 'Несохранённые изменения',
|
||||
'note.unsavedMessage': 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
|
||||
'note.unsavedClose': 'Закрыть',
|
||||
|
||||
'file.addFile': '+ Добавить файл',
|
||||
'file.addFolder': '+ Добавить папку',
|
||||
'file.newFile': '+ Новый файл',
|
||||
'file.addFileSimple': 'Добавить файл',
|
||||
'file.addFolderSimple': 'Добавить папку',
|
||||
'file.noFiles': 'В этой папке пока нет файлов',
|
||||
'file.noFilesCase': 'В этом проекте пока нет файлов',
|
||||
'file.hint': 'Добавьте файл или папку, чтобы сохранить материалы проекта.',
|
||||
'file.root': 'Файлы',
|
||||
'file.preview': 'Предпросмотр',
|
||||
'file.openExternal': 'Открыть во внешней программе',
|
||||
'file.openFolder': 'Открыть папку',
|
||||
'file.showInExplorer': 'Показать в проводнике',
|
||||
'file.more': 'Ещё',
|
||||
'file.delete': 'Удалить',
|
||||
'file.ariaFolder': 'Папка',
|
||||
'file.ariaFile': 'Файл',
|
||||
'file.scanning': 'Сканирование...',
|
||||
'file.pickSingle': 'Выберите файл',
|
||||
'file.pickMultiple': 'Выберите файлы',
|
||||
'file.pickDirectory': 'Выберите папку',
|
||||
'file.importTitle': 'Добавить в',
|
||||
'file.importFiles': 'Файлов:',
|
||||
'file.importFolders': 'Папок:',
|
||||
'file.importSize': 'Размер:',
|
||||
'file.importCopy': 'Скопировать',
|
||||
'file.importLink': 'Привязать',
|
||||
'file.selectCaseFirst': 'Сначала выберите дело для добавления файлов',
|
||||
|
||||
'worklog.title': 'Журнал',
|
||||
'worklog.whatDone': 'Что сделано',
|
||||
'worklog.minutes': 'Мин',
|
||||
'worklog.min': 'мин',
|
||||
'worklog.log': 'Записать',
|
||||
'worklog.empty': 'Записей работы пока нет',
|
||||
|
||||
'sync.title': 'Синхронизация',
|
||||
'sync.settings': 'Настройки синхронизации',
|
||||
'sync.status': 'Статус',
|
||||
'sync.server': 'Сервер',
|
||||
'sync.device': 'Устройство',
|
||||
'sync.deviceId': 'ID устройства',
|
||||
'sync.unpushed': 'Неотправлено',
|
||||
'sync.lastSync': 'Последняя синх.',
|
||||
'sync.revoked': 'Отозвано',
|
||||
'sync.connected': 'Подключено',
|
||||
'sync.notConnected': 'Не подключено',
|
||||
'sync.disabled': 'Отключена',
|
||||
'sync.serverUrl': 'URL сервера',
|
||||
'sync.serverUrlPlaceholder': 'https://example.com:47732',
|
||||
'sync.username': 'Логин',
|
||||
'sync.usernamePlaceholder': 'username',
|
||||
'sync.password': 'Пароль',
|
||||
'sync.passwordPlaceholder': 'password',
|
||||
'sync.autoSync': 'Автосинхронизация (мин, 0 = отключено)',
|
||||
'sync.saveInterval': 'Сохранить интервал',
|
||||
'sync.syncNow': 'Синхронизировать',
|
||||
'sync.disconnect': 'Отключиться',
|
||||
'sync.connect': 'Подключиться',
|
||||
'sync.test': 'Проверить',
|
||||
'sync.settingsSaved': 'интервал сохранён',
|
||||
|
||||
'today.title': 'Сегодня',
|
||||
'today.changedCases': 'Изменён сегодня',
|
||||
'today.timeline': 'Лента за сегодня',
|
||||
'today.empty': 'Сегодня пока тихо',
|
||||
'today.emptyHint': 'Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.',
|
||||
'today.plural.case_one': 'дело',
|
||||
'today.plural.case_few': 'дела',
|
||||
'today.plural.case_many': 'дел',
|
||||
'today.plural.note_one': 'заметка',
|
||||
'today.plural.note_few': 'заметки',
|
||||
'today.plural.note_many': 'заметок',
|
||||
'today.plural.file_one': 'файл',
|
||||
'today.plural.file_few': 'файла',
|
||||
'today.plural.file_many': 'файлов',
|
||||
'today.plural.event_one': 'событие',
|
||||
'today.plural.event_few': 'события',
|
||||
'today.plural.event_many': 'событий',
|
||||
|
||||
'activity.title': 'Активность',
|
||||
'activity.empty': 'Активность пока не зафиксирована',
|
||||
'activity.perCaseEmpty': 'Активность пока не зафиксирована',
|
||||
|
||||
'overview.type': 'Тип',
|
||||
'overview.section': 'Раздел',
|
||||
'overview.created': 'Создано',
|
||||
'overview.newNote': 'Новая заметка',
|
||||
'overview.addFile': 'Добавить файл',
|
||||
'overview.addAction': 'Добавить действие',
|
||||
'overview.logTime': 'Записать время',
|
||||
'overview.recentNotes': 'Последние заметки',
|
||||
'overview.recentEntries': 'Последние записи',
|
||||
|
||||
'rename.title': 'Переименовать',
|
||||
'rename.emptyError': 'Имя не может быть пустым',
|
||||
'rename.invalidError': 'Недопустимое имя',
|
||||
|
||||
'delete.confirmTitle': 'Удаление',
|
||||
'delete.confirmMessage': 'Удалить',
|
||||
'delete.folder': 'папку',
|
||||
'delete.file': 'файл',
|
||||
|
||||
'template.optionNone': 'Без шаблона',
|
||||
'template.optional': 'Шаблон (опционально)',
|
||||
|
||||
'mime.jpeg': 'Изображение JPEG',
|
||||
'mime.png': 'Изображение PNG',
|
||||
'mime.gif': 'Изображение GIF',
|
||||
'mime.webp': 'Изображение WebP',
|
||||
'mime.svg': 'Изображение SVG',
|
||||
'mime.bmp': 'Изображение BMP',
|
||||
'mime.tiff': 'Изображение TIFF',
|
||||
'mime.avif': 'Изображение AVIF',
|
||||
'mime.pdf': 'PDF документ',
|
||||
'mime.word': 'Документ Word',
|
||||
'mime.excel': 'Таблица Excel',
|
||||
'mime.ppt': 'Презентация PowerPoint',
|
||||
'mime.zip': 'ZIP архив',
|
||||
'mime.gzip': 'GZIP архив',
|
||||
'mime.tar': 'TAR архив',
|
||||
'mime.sevenz': '7z архив',
|
||||
'mime.rar': 'RAR архив',
|
||||
'mime.text': 'Текстовый файл',
|
||||
'mime.html': 'HTML файл',
|
||||
'mime.css': 'CSS файл',
|
||||
'mime.js': 'JavaScript файл',
|
||||
'mime.json': 'JSON файл',
|
||||
'mime.xml': 'XML файл',
|
||||
'mime.yaml': 'YAML файл',
|
||||
'mime.binary': 'Бинарный файл',
|
||||
'mime.executable': 'Исполняемый файл',
|
||||
'mime.folder': 'Папка',
|
||||
'mime.unknown': 'Неизвестно',
|
||||
'mime.file': 'Файл',
|
||||
|
||||
'error.nameEmpty': 'Имя не может быть пустым',
|
||||
'error.nameInvalid': 'Недопустимое имя',
|
||||
'error.selectCaseFirst': 'Сначала выберите дело',
|
||||
'error.generic': 'Произошла ошибка',
|
||||
'error.invalidCredentials': 'Неверный логин или пароль',
|
||||
'error.accountBlocked': 'Аккаунт заблокирован',
|
||||
'error.emailNotConfirmed': 'Email не подтверждён',
|
||||
'error.tokenInvalid': 'Неверный или просроченный токен',
|
||||
'error.tokenExpired': 'Срок действия токена истёк',
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SafeVaultPath validates that relPath is a safe relative path within vaultRoot.
|
||||
// It rejects absolute paths, paths with ".." that escape vaultRoot, and empty paths.
|
||||
func SafeVaultPath(vaultRoot, relPath string) (string, error) {
|
||||
if relPath == "" {
|
||||
return "", fmt.Errorf("empty path")
|
||||
}
|
||||
if filepath.IsAbs(relPath) {
|
||||
return "", fmt.Errorf("absolute path not allowed: %s", relPath)
|
||||
}
|
||||
clean := filepath.Clean(relPath)
|
||||
if strings.HasPrefix(clean, "..") || strings.Contains(clean, "../") || strings.HasPrefix(clean, "\\..") {
|
||||
return "", fmt.Errorf("path escapes vault: %s", relPath)
|
||||
}
|
||||
joined := filepath.Join(vaultRoot, clean)
|
||||
// Verify we're still inside vaultRoot after Clean.
|
||||
if !strings.HasPrefix(joined, filepath.Clean(vaultRoot)+string(filepath.Separator)) && joined != filepath.Clean(vaultRoot) {
|
||||
return "", fmt.Errorf("path escapes vault after join: %s", relPath)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// SafeVaultPaths validates multiple paths and returns the first error.
|
||||
func SafeVaultPaths(vaultRoot string, paths ...string) error {
|
||||
for _, p := range paths {
|
||||
if _, err := SafeVaultPath(vaultRoot, p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed locales/*.json
|
||||
var localeFS embed.FS
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
cache = map[string]map[string]string{}
|
||||
defaultLocale = "ru"
|
||||
)
|
||||
|
||||
// T returns a localized string for the given locale and key.
|
||||
// It supports printf-style formatting via args.
|
||||
// Falls back: key not found -> ru -> key itself.
|
||||
func T(locale, key string, args ...any) string {
|
||||
mu.RLock()
|
||||
catalog, ok := cache[locale]
|
||||
mu.RUnlock()
|
||||
if !ok {
|
||||
catalog = loadLocale(locale)
|
||||
}
|
||||
msg, ok := catalog[key]
|
||||
if !ok && locale != defaultLocale {
|
||||
mu.RLock()
|
||||
ruCatalog, ruOK := cache[defaultLocale]
|
||||
mu.RUnlock()
|
||||
if ruOK {
|
||||
msg = ruCatalog[key]
|
||||
}
|
||||
if msg == "" {
|
||||
msg = key
|
||||
}
|
||||
}
|
||||
if msg == "" {
|
||||
msg = key
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return fmt.Sprintf(msg, args...)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// TF is a shorthand for T with formatting.
|
||||
func TF(locale, key string, args ...any) string {
|
||||
return T(locale, key, args...)
|
||||
}
|
||||
|
||||
// SetDefault changes the default locale.
|
||||
func SetDefault(locale string) {
|
||||
mu.Lock()
|
||||
defaultLocale = locale
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// AvailableLocales returns locale names for which files exist.
|
||||
func AvailableLocales() []string {
|
||||
entries, err := localeFS.ReadDir("locales")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
name = strings.TrimSuffix(name, ".json")
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadLocale(locale string) map[string]string {
|
||||
data, err := localeFS.ReadFile("locales/" + locale + ".json")
|
||||
if err != nil {
|
||||
data = []byte("{}")
|
||||
}
|
||||
var m map[string]string
|
||||
json.Unmarshal(data, &m)
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
mu.Lock()
|
||||
cache[locale] = m
|
||||
mu.Unlock()
|
||||
return m
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
{
|
||||
"nav.today": "Today",
|
||||
"nav.inbox": "Inbox",
|
||||
"nav.activity": "Activity",
|
||||
"nav.clients": "Clients",
|
||||
"nav.projects": "Projects",
|
||||
"nav.recipes": "Recipes",
|
||||
"nav.documents": "Documents",
|
||||
"nav.archive": "Archive",
|
||||
|
||||
"tab.overview": "Overview",
|
||||
"tab.notes": "Notes",
|
||||
"tab.files": "Files",
|
||||
"tab.actions": "Actions",
|
||||
"tab.worklog": "Work Log",
|
||||
"tab.activity": "Activity",
|
||||
|
||||
"common.save": "Save",
|
||||
"common.cancel": "Cancel",
|
||||
"common.delete": "Delete",
|
||||
"common.rename": "Rename",
|
||||
"common.close": "Close",
|
||||
"common.create": "Create",
|
||||
"common.confirm": "Confirm",
|
||||
"common.back": "← Back",
|
||||
"common.loading": "Loading...",
|
||||
"common.error": "Error:",
|
||||
"common.yes": "Yes",
|
||||
"common.ok": "OK",
|
||||
"common.run": "Run",
|
||||
"common.name": "Name",
|
||||
"common.settings": "Settings",
|
||||
|
||||
"welcome.title": "Verstak",
|
||||
"welcome.selectSection": "Select a section in the sidebar.",
|
||||
"welcome.addCase": "Add case",
|
||||
|
||||
"event.noteCreated": "Note created",
|
||||
"event.noteUpdated": "Note updated",
|
||||
"event.fileAdded": "File added",
|
||||
"event.fileDeleted": "File deleted",
|
||||
"event.fileRenamed": "File renamed",
|
||||
"event.fileCopied": "File copied",
|
||||
"event.fileMoved": "File moved",
|
||||
"event.caseCreated": "Case created",
|
||||
|
||||
"action.openUrl": "Open URL",
|
||||
"action.openFile": "Open file",
|
||||
"action.openFolder": "Open folder",
|
||||
"action.runCommand": "Run command",
|
||||
"action.runScript": "Run script",
|
||||
"action.openTerminal": "Open terminal",
|
||||
"action.launchApp": "Launch app",
|
||||
|
||||
"note.add": "+ Add note",
|
||||
"note.noNotes": "No notes",
|
||||
"note.title": "Note title",
|
||||
"note.placeholder": "Start writing...",
|
||||
|
||||
"file.addFile": "+ Add file",
|
||||
"file.addFolder": "+ Add folder",
|
||||
"file.preview": "Preview",
|
||||
"file.openExternal": "Open in external program",
|
||||
"file.openFolder": "Open folder",
|
||||
"file.delete": "Delete",
|
||||
"file.pickSingle": "Select file",
|
||||
"file.pickDirectory": "Select folder",
|
||||
|
||||
"sync.title": "Sync",
|
||||
"sync.settings": "Sync settings",
|
||||
"sync.status": "Status",
|
||||
"sync.server": "Server",
|
||||
"sync.device": "Device",
|
||||
"sync.connected": "Connected",
|
||||
"sync.notConnected": "Not connected",
|
||||
"sync.disabled": "Disabled",
|
||||
|
||||
"server.registerBtn": "Register",
|
||||
"server.loginBtn": "Log in",
|
||||
"server.logout": "Log out",
|
||||
"server.username": "Username",
|
||||
"server.email": "Email",
|
||||
"server.password": "Password",
|
||||
"server.save": "Save",
|
||||
"server.back": "← Back",
|
||||
|
||||
"admin.devices": "Devices",
|
||||
"admin.users": "Users",
|
||||
"admin.smtp": "SMTP Settings",
|
||||
"admin.healthCheck": "Health check",
|
||||
"admin.status": "Status",
|
||||
"admin.active": "Active",
|
||||
"admin.revoked": "Revoked",
|
||||
|
||||
"error.generic": "An error occurred",
|
||||
"error.invalidCredentials": "Invalid username or password",
|
||||
"error.accountBlocked": "Account blocked",
|
||||
"error.emailNotConfirmed": "Email not confirmed",
|
||||
"error.tokenInvalid": "Invalid or expired token",
|
||||
"error.tokenExpired": "Token expired"
|
||||
}
|
||||
|
|
@ -0,0 +1,383 @@
|
|||
{
|
||||
"nav.today": "Сегодня",
|
||||
"nav.inbox": "Неразобранное",
|
||||
"nav.activity": "Активность",
|
||||
"nav.clients": "Клиенты",
|
||||
"nav.projects": "Проекты",
|
||||
"nav.recipes": "Рецепты",
|
||||
"nav.documents": "Документы",
|
||||
"nav.archive": "Архив",
|
||||
"nav.sections": "Разделы",
|
||||
"nav.cases": "Дела",
|
||||
"nav.noCases": "Нет дел",
|
||||
"nav.sync": "Синхронизация",
|
||||
"nav.syncSettings": "Настройки синхронизации",
|
||||
"nav.syncNow": "Синхронизировать",
|
||||
"nav.selectPrompt": "Выберите раздел или дело",
|
||||
"nav.brand": "Верстак",
|
||||
|
||||
"tab.overview": "Обзор",
|
||||
"tab.notes": "Заметки",
|
||||
"tab.files": "Файлы",
|
||||
"tab.actions": "Действия",
|
||||
"tab.worklog": "Журнал",
|
||||
"tab.activity": "Активность",
|
||||
|
||||
"common.save": "Сохранить",
|
||||
"common.cancel": "Отмена",
|
||||
"common.delete": "Удалить",
|
||||
"common.rename": "Переименовать",
|
||||
"common.close": "Закрыть",
|
||||
"common.create": "Создать",
|
||||
"common.confirm": "Подтверждение",
|
||||
"common.back": "← Назад",
|
||||
"common.loading": "Загрузка...",
|
||||
"common.error": "Ошибка:",
|
||||
"common.yes": "Да",
|
||||
"common.ok": "OK",
|
||||
"common.copy": "Копировать",
|
||||
"common.cut": "Вырезать",
|
||||
"common.paste": "Вставить",
|
||||
"common.duplicate": "Дублировать",
|
||||
"common.run": "Запустить",
|
||||
"common.test": "Test",
|
||||
"common.testAgain": "Проверить",
|
||||
"common.connect": "Подключиться",
|
||||
"common.disconnect": "Отключиться",
|
||||
"common.settings": "Настройки",
|
||||
"common.name": "Название",
|
||||
"common.type": "Тип",
|
||||
"common.section": "Раздел",
|
||||
"common.created": "Создано",
|
||||
"common.empty": "Нет",
|
||||
"common.newName": "Новое имя",
|
||||
|
||||
"welcome.title": "Верстак",
|
||||
"welcome.selectSection": "Выберите раздел в боковой панели.",
|
||||
"welcome.createCase": "Или создайте новое дело кнопкой «+».",
|
||||
"welcome.addCase": "Добавить дело",
|
||||
|
||||
"event.noteCreated": "Заметка создана",
|
||||
"event.noteUpdated": "Заметка изменена",
|
||||
"event.fileAdded": "Файл добавлен",
|
||||
"event.fileDeleted": "Файл удалён",
|
||||
"event.fileRenamed": "Файл переименован",
|
||||
"event.fileCopied": "Файл скопирован",
|
||||
"event.fileMoved": "Файл перемещён",
|
||||
"event.folderAdded": "Папка добавлена",
|
||||
"event.folderDeleted": "Папка удалена",
|
||||
"event.folderRenamed": "Папка переименована",
|
||||
"event.caseCreated": "Дело создано",
|
||||
"event.caseUpdated": "Дело изменено",
|
||||
|
||||
"kind.project": "Проект",
|
||||
"kind.client": "Клиент",
|
||||
"kind.document": "Документ",
|
||||
"kind.recipe": "Рецепт",
|
||||
"kind.archive": "Архив",
|
||||
"kind.case": "Дело",
|
||||
|
||||
"action.openUrl": "Открыть URL",
|
||||
"action.openFile": "Открыть файл",
|
||||
"action.openFolder": "Открыть папку",
|
||||
"action.runCommand": "Запустить команду",
|
||||
"action.runScript": "Запустить скрипт",
|
||||
"action.openTerminal": "Открыть терминал",
|
||||
"action.launchApp": "Запустить приложение",
|
||||
"action.addAction": "+ Добавить действие",
|
||||
"action.newAction": "Новое действие",
|
||||
"action.noActions": "Действий пока нет",
|
||||
"action.run": "Запустить",
|
||||
"action.dataUrl": "URL",
|
||||
"action.dataPath": "Путь",
|
||||
"action.dataCommand": "Команда",
|
||||
"action.urlPlaceholder": "https://example.com",
|
||||
"action.pathPlaceholder": "/path/to/file",
|
||||
"action.commandPlaceholder": "команда",
|
||||
"action.namePlaceholder": "Например: Открыть сайт",
|
||||
|
||||
"note.add": "+ Добавить заметку",
|
||||
"note.new": "Новая заметка",
|
||||
"note.title": "Название заметки",
|
||||
"note.noNotes": "Нет заметок",
|
||||
"note.createFirst": "Создайте первую заметку для этого дела.",
|
||||
"note.placeholder": "Начните писать...",
|
||||
"note.unsavedTitle": "Несохранённые изменения",
|
||||
"note.unsavedMessage": "Закрыть редактор? Все несохранённые изменения будут потеряны.",
|
||||
"note.unsavedClose": "Закрыть",
|
||||
|
||||
"file.addFile": "+ Добавить файл",
|
||||
"file.addFolder": "+ Добавить папку",
|
||||
"file.newFile": "+ Новый файл",
|
||||
"file.addFileSimple": "Добавить файл",
|
||||
"file.addFolderSimple": "Добавить папку",
|
||||
"file.noFiles": "В этой папке пока нет файлов",
|
||||
"file.noFilesCase": "В этом проекте пока нет файлов",
|
||||
"file.hint": "Добавьте файл или папку, чтобы сохранить материалы проекта.",
|
||||
"file.root": "Файлы",
|
||||
"file.preview": "Предпросмотр",
|
||||
"file.openExternal": "Открыть во внешней программе",
|
||||
"file.openFolder": "Открыть папку",
|
||||
"file.showInExplorer": "Показать в проводнике",
|
||||
"file.more": "Ещё",
|
||||
"file.delete": "Удалить",
|
||||
"file.ariaFolder": "Папка",
|
||||
"file.ariaFile": "Файл",
|
||||
"file.scanning": "Сканирование...",
|
||||
"file.pickSingle": "Выберите файл",
|
||||
"file.pickMultiple": "Выберите файлы",
|
||||
"file.pickDirectory": "Выберите папку",
|
||||
"file.importTitle": "Добавить в",
|
||||
"file.importFiles": "Файлов:",
|
||||
"file.importFolders": "Папок:",
|
||||
"file.importSize": "Размер:",
|
||||
"file.importCopy": "Скопировать",
|
||||
"file.importLink": "Привязать",
|
||||
"file.selectCaseFirst": "Сначала выберите дело для добавления файлов",
|
||||
|
||||
"worklog.title": "Журнал",
|
||||
"worklog.whatDone": "Что сделано",
|
||||
"worklog.minutes": "Мин",
|
||||
"worklog.min": "мин",
|
||||
"worklog.log": "Записать",
|
||||
"worklog.empty": "Записей работы пока нет",
|
||||
|
||||
"sync.title": "Синхронизация",
|
||||
"sync.settings": "Настройки синхронизации",
|
||||
"sync.status": "Статус",
|
||||
"sync.server": "Сервер",
|
||||
"sync.device": "Устройство",
|
||||
"sync.deviceId": "ID устройства",
|
||||
"sync.unpushed": "Неотправлено",
|
||||
"sync.lastSync": "Последняя синх.",
|
||||
"sync.revoked": "Отозвано",
|
||||
"sync.connected": "Подключено",
|
||||
"sync.notConnected": "Не подключено",
|
||||
"sync.disabled": "Отключена",
|
||||
"sync.serverUrl": "URL сервера",
|
||||
"sync.serverUrlPlaceholder": "https://example.com:47732",
|
||||
"sync.username": "Логин",
|
||||
"sync.usernamePlaceholder": "username",
|
||||
"sync.password": "Пароль",
|
||||
"sync.passwordPlaceholder": "password",
|
||||
"sync.autoSync": "Автосинхронизация (мин, 0 = отключено)",
|
||||
"sync.saveInterval": "Сохранить интервал",
|
||||
"sync.syncNow": "Синхронизировать",
|
||||
"sync.disconnect": "Отключиться",
|
||||
"sync.connect": "Подключиться",
|
||||
"sync.test": "Проверить",
|
||||
"sync.settingsSaved": "интервал сохранён",
|
||||
|
||||
"today.title": "Сегодня",
|
||||
"today.changedCases": "Изменён сегодня",
|
||||
"today.timeline": "Лента за сегодня",
|
||||
"today.empty": "Сегодня пока тихо",
|
||||
"today.emptyHint": "Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.",
|
||||
"today.plural.case_one": "дело",
|
||||
"today.plural.case_few": "дела",
|
||||
"today.plural.case_many": "дел",
|
||||
"today.plural.note_one": "заметка",
|
||||
"today.plural.note_few": "заметки",
|
||||
"today.plural.note_many": "заметок",
|
||||
"today.plural.file_one": "файл",
|
||||
"today.plural.file_few": "файла",
|
||||
"today.plural.file_many": "файлов",
|
||||
"today.plural.event_one": "событие",
|
||||
"today.plural.event_few": "события",
|
||||
"today.plural.event_many": "событий",
|
||||
|
||||
"activity.title": "Активность",
|
||||
"activity.empty": "Активность пока не зафиксирована",
|
||||
"activity.perCaseEmpty": "Активность пока не зафиксирована",
|
||||
|
||||
"overview.type": "Тип",
|
||||
"overview.section": "Раздел",
|
||||
"overview.created": "Создано",
|
||||
"overview.newNote": "Новая заметка",
|
||||
"overview.addFile": "Добавить файл",
|
||||
"overview.addAction": "Добавить действие",
|
||||
"overview.logTime": "Записать время",
|
||||
"overview.recentNotes": "Последние заметки",
|
||||
"overview.recentEntries": "Последние записи",
|
||||
|
||||
"rename.title": "Переименовать",
|
||||
"rename.emptyError": "Имя не может быть пустым",
|
||||
"rename.invalidError": "Недопустимое имя",
|
||||
|
||||
"delete.confirmTitle": "Удаление",
|
||||
"delete.confirmMessage": "Удалить",
|
||||
"delete.folder": "папку",
|
||||
"delete.file": "файл",
|
||||
|
||||
"template.optionNone": "Без шаблона",
|
||||
"template.optional": "Шаблон (опционально)",
|
||||
|
||||
"mime.jpeg": "Изображение JPEG",
|
||||
"mime.png": "Изображение PNG",
|
||||
"mime.gif": "Изображение GIF",
|
||||
"mime.webp": "Изображение WebP",
|
||||
"mime.svg": "Изображение SVG",
|
||||
"mime.bmp": "Изображение BMP",
|
||||
"mime.tiff": "Изображение TIFF",
|
||||
"mime.avif": "Изображение AVIF",
|
||||
"mime.pdf": "PDF документ",
|
||||
"mime.word": "Документ Word",
|
||||
"mime.excel": "Таблица Excel",
|
||||
"mime.ppt": "Презентация PowerPoint",
|
||||
"mime.zip": "ZIP архив",
|
||||
"mime.gzip": "GZIP архив",
|
||||
"mime.tar": "TAR архив",
|
||||
"mime.sevenz": "7z архив",
|
||||
"mime.rar": "RAR архив",
|
||||
"mime.text": "Текстовый файл",
|
||||
"mime.html": "HTML файл",
|
||||
"mime.css": "CSS файл",
|
||||
"mime.js": "JavaScript файл",
|
||||
"mime.json": "JSON файл",
|
||||
"mime.xml": "XML файл",
|
||||
"mime.yaml": "YAML файл",
|
||||
"mime.binary": "Бинарный файл",
|
||||
"mime.executable": "Исполняемый файл",
|
||||
"mime.folder": "Папка",
|
||||
"mime.unknown": "Неизвестно",
|
||||
"mime.file": "Файл",
|
||||
|
||||
"server.register": "Регистрация",
|
||||
"server.registerTitle": "Verstak Sync — Регистрация",
|
||||
"server.registerBtn": "Зарегистрироваться",
|
||||
"server.login": "Вход",
|
||||
"server.loginTitle": "Verstak Sync — Вход",
|
||||
"server.loginBtn": "Войти",
|
||||
"server.logout": "Выйти",
|
||||
"server.username": "Логин",
|
||||
"server.usernameOrEmail": "Логин или Email",
|
||||
"server.email": "Email",
|
||||
"server.password": "Пароль",
|
||||
"server.passwordConfirm": "Подтвердите пароль",
|
||||
"server.passwordHint": "Минимум 8 символов: латинские буквы + цифры",
|
||||
"server.forgotPassword": "Забыли пароль?",
|
||||
"server.adminLink": "Администратор?",
|
||||
"server.alreadyHaveAccount": "Уже есть аккаунт?",
|
||||
"server.backToLogin": "← Вспомнили пароль?",
|
||||
"server.goHome": "На главную",
|
||||
"server.needEmail": "Email обязателен",
|
||||
"server.allFieldsRequired": "Все поля обязательны",
|
||||
"server.passwordsDoNotMatch": "Пароли не совпадают",
|
||||
"server.resetPasswordTitle": "Verstak Sync — Восстановление пароля",
|
||||
"server.resetPassword": "Восстановление пароля",
|
||||
"server.resetInstruction": "Введите email, указанный при регистрации",
|
||||
"server.sendLink": "Отправить ссылку",
|
||||
"server.emailSentTitle": "Verstak Sync — Письмо отправлено",
|
||||
"server.emailSent": "✓ Письмо отправлено",
|
||||
"server.emailSentMessage": "Если указанный email зарегистрирован, на него придёт ссылка для сброса пароля.",
|
||||
"server.newPasswordTitle": "Verstak Sync — Новый пароль",
|
||||
"server.newPassword": "Новый пароль",
|
||||
"server.passwordChanged": "✓ Пароль изменён",
|
||||
"server.passwordChangedMessage": "Теперь вы можете войти с новым паролем.",
|
||||
"server.save": "Сохранить",
|
||||
"server.emailConfirmed": "✓ Email подтверждён",
|
||||
"server.emailConfirmedMessage": "Ваш email успешно подтверждён. Теперь вы можете войти в систему.",
|
||||
"server.registrationSuccess": "✓ Регистрация успешна",
|
||||
"server.registrationEmailSent": "На вашу почту отправлено письмо с подтверждением.",
|
||||
"server.registrationCheckEmail": "Перейдите по ссылке в письме, чтобы активировать аккаунт.",
|
||||
"server.registrationAutoSuccess": "✓ Регистрация успешна",
|
||||
"server.registrationAutoMessage": "Вы можете войти — подтверждение email не требуется.",
|
||||
"server.back": "← Назад",
|
||||
"server.dashboard": "← Дашборд",
|
||||
"server.users": "Пользователи",
|
||||
"server.adminPwdHint": "Минимум 8 символов, латинские буквы и цифры",
|
||||
"server.newPasswordResult": "Новый пароль: %s\nСообщите его пользователю.",
|
||||
|
||||
"admin.login": "Verstak Sync — Admin Login",
|
||||
"admin.dashboard": "Verstak Sync — Admin",
|
||||
"admin.users": "Verstak Sync — Пользователи",
|
||||
"admin.usersHeading": "Пользователи",
|
||||
"admin.username": "Логин",
|
||||
"admin.email": "Email",
|
||||
"admin.password": "Пароль",
|
||||
"admin.loginBtn": "Войти",
|
||||
"admin.devices": "Устройства",
|
||||
"admin.deviceCount": "Устройств:",
|
||||
"admin.opsCount": "Операций:",
|
||||
"admin.smtp": "Настройка SMTP",
|
||||
"admin.smtpTitle": "SMTP (для писем)",
|
||||
"admin.smtpServer": "Сервер",
|
||||
"admin.smtpPort": "Порт",
|
||||
"admin.smtpType": "Тип",
|
||||
"admin.smtpNoEncryption": "Без шифрования",
|
||||
"admin.smtpUsername": "Логин",
|
||||
"admin.smtpPassword": "Пароль",
|
||||
"admin.smtpFrom": "От кого",
|
||||
"admin.smtpServerURL": "URL сервера",
|
||||
"admin.smtpSave": "Сохранить SMTP",
|
||||
"admin.smtpTest": "Test",
|
||||
"admin.smtpTesting": "⏳ Тестируем...",
|
||||
"admin.smtpPassed": "✓ Тест пройден",
|
||||
"admin.smtpFailed": "✗",
|
||||
"admin.healthCheck": "Health check",
|
||||
"admin.healthLoading": "Загрузка...",
|
||||
"admin.noDevices": "Нет устройств",
|
||||
"admin.device": "Устройство",
|
||||
"admin.user": "Пользователь",
|
||||
"admin.version": "Версия",
|
||||
"admin.status": "Статус",
|
||||
"admin.active": "Активно",
|
||||
"admin.revoked": "Отозвано",
|
||||
"admin.lastSeen": "Активность",
|
||||
"admin.revoke": "Отозвать",
|
||||
"admin.revokeConfirm": "Отозвать устройство?",
|
||||
"admin.filterPlaceholder": "Фильтр по логину...",
|
||||
"admin.actions": "Действия",
|
||||
"admin.confirmed": "Подтверждён",
|
||||
"admin.unconfirmed": "Не подтверждён",
|
||||
"admin.blocked": "Заблокирован",
|
||||
"admin.unblock": "Разблокировать",
|
||||
"admin.block": "Заблокировать",
|
||||
"admin.resetPassword": "Сброс пароля",
|
||||
"admin.resetPasswordConfirm": "Сбросить пароль?",
|
||||
"admin.resetPasswordMessage": "Пользователь не сможет войти со старым паролем.",
|
||||
"admin.resetBtn": "Сбросить",
|
||||
"admin.editUser": "Редактировать пользователя",
|
||||
"admin.editBtn": "Сохранить",
|
||||
"admin.deleteUser": "Удалить пользователя?",
|
||||
"admin.deleteUserMessage": "Будет удалён пользователь «%s» и все его устройства.",
|
||||
"admin.deleteBtn": "Удалить",
|
||||
"admin.resultTitle": "Результат",
|
||||
"admin.confirmTitle": "Подтверждение",
|
||||
"admin.modalCancel": "Отмена",
|
||||
"admin.modalConfirm": "Да",
|
||||
"admin.noUsers": "Нет пользователей",
|
||||
"admin.unblockUserTitle": "Разблокировать пользователя?",
|
||||
"admin.unblockUserMessage": "Пользователь сможет снова войти.",
|
||||
"admin.blockUserTitle": "Заблокировать пользователя?",
|
||||
"admin.blockUserMessage": "Пользователь не сможет войти.",
|
||||
"admin.unblockBtn": "Разблокировать",
|
||||
"admin.blockBtn": "Заблокировать",
|
||||
|
||||
"userDashboard.title": "Verstak Sync",
|
||||
"userDashboard.devices": "Устройства",
|
||||
"userDashboard.connectNew": "Подключить новое устройство",
|
||||
"userDashboard.connectNewHint": "Откройте desktop-клиент Verstak, перейдите в настройки синхронизации и введите URL сервера, логин и пароль.",
|
||||
"userDashboard.noDevices": "Нет подключённых устройств.<br>Подключите устройство из desktop-клиента Verstak.",
|
||||
"userDashboard.device": "Устройство",
|
||||
"userDashboard.status": "Статус",
|
||||
"userDashboard.connected": "Подключено",
|
||||
"userDashboard.lastSeen": "Активность",
|
||||
"userDashboard.version": "Версия",
|
||||
"userDashboard.active": "Активно",
|
||||
"userDashboard.revoked": "Отозвано",
|
||||
"userDashboard.revoke": "Отозвать",
|
||||
"userDashboard.revokeConfirm": "Отозвать устройство? Оно перестанет синхронизироваться.",
|
||||
"userDashboard.revokePrompt": "Введите ваш пароль для подтверждения:",
|
||||
"userDashboard.logout": "Выйти",
|
||||
|
||||
"error.nameEmpty": "Имя не может быть пустым",
|
||||
"error.nameInvalid": "Недопустимое имя",
|
||||
"error.selectCaseFirst": "Сначала выберите дело",
|
||||
"error.generic": "Произошла ошибка",
|
||||
"error.invalidCredentials": "Неверный логин или пароль",
|
||||
"error.accountBlocked": "Аккаунт заблокирован",
|
||||
"error.emailNotConfirmed": "Email не подтверждён",
|
||||
"error.tokenInvalid": "Неверный или просроченный токен",
|
||||
"error.tokenExpired": "Срок действия токена истёк"
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
#!/bin/bash
|
||||
# Check for hardcoded Russian/Cyrillic user-facing strings in source code.
|
||||
# Excludes locale files, docs, tests with explicit locale checks.
|
||||
|
||||
set -e
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
EXIT=0
|
||||
|
||||
# Find Cyrillic characters in source files, excluding allowed paths.
|
||||
# The regex matches any Cyrillic character range.
|
||||
# Allowed exceptions:
|
||||
# - locale files (*/i18n/locales/*)
|
||||
# - docs/*
|
||||
# - README*
|
||||
# - spaces/
|
||||
# - .json files that are templates or configs
|
||||
# - .md files
|
||||
|
||||
echo "=== Checking for hardcoded Cyrillic in source code ==="
|
||||
|
||||
# Search for Cyrillic characters in Go files (excluding locale files)
|
||||
GO_CYRILLIC=$(find "$ROOT" -name '*.go' \
|
||||
! -path "*/i18n/locales/*" \
|
||||
-exec grep -l '[А-Яа-я]' {} \; 2>/dev/null || true)
|
||||
|
||||
if [ -n "$GO_CYRILLIC" ]; then
|
||||
echo "WARNING: Cyrillic found in Go files (expected in server HTML templates for now):"
|
||||
echo "$GO_CYRILLIC"
|
||||
# Don't fail for Go files with HTML templates — they'll be refactored later
|
||||
fi
|
||||
|
||||
# Search for Cyrillic in Svelte/JS files (excluding locale files)
|
||||
JS_CYRILLIC=$(find "$ROOT/frontend/src" -name '*.svelte' -o -name '*.js' | \
|
||||
grep -v 'i18n/locales' | \
|
||||
xargs grep -l '[А-Яа-я]' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$JS_CYRILLIC" ]; then
|
||||
echo ""
|
||||
echo "FAIL: Cyrillic found in frontend source files (outside locale files):"
|
||||
echo "$JS_CYRILLIC"
|
||||
echo ""
|
||||
echo "These should use t() from lib/i18n instead."
|
||||
EXIT=1
|
||||
fi
|
||||
|
||||
# Check for common bidi/control unicode characters
|
||||
echo ""
|
||||
echo "=== Checking for bidi/control Unicode characters ==="
|
||||
BIDI=$(find "$ROOT" -name '*.go' -o -name '*.svelte' -o -name '*.js' | \
|
||||
xargs grep -Pl '[\x{202A}-\x{202E}\x{2066}-\x{2069}\x{200E}\x{200F}\x{061C}]' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$BIDI" ]; then
|
||||
echo "FAIL: Bidi/control Unicode characters found in:"
|
||||
echo "$BIDI"
|
||||
EXIT=1
|
||||
else
|
||||
echo "OK: No bidi/control characters found"
|
||||
fi
|
||||
|
||||
# Check that locale keys in ru.js and en.js match
|
||||
echo ""
|
||||
echo "=== Checking locale key consistency ==="
|
||||
RU_KEYS=$(grep -oP "^\s+'[^']+'" "$ROOT/frontend/src/lib/i18n/locales/ru.js" | sed "s/^ *'//;s/'$//" | sort)
|
||||
EN_KEYS=$(grep -oP "^\s+'[^']+'" "$ROOT/frontend/src/lib/i18n/locales/en.js" | sed "s/^ *'//;s/'$//" | sort)
|
||||
|
||||
MISSING_EN=$(comm -23 <(echo "$RU_KEYS") <(echo "$EN_KEYS"))
|
||||
MISSING_RU=$(comm -23 <(echo "$EN_KEYS") <(echo "$RU_KEYS"))
|
||||
|
||||
if [ -n "$MISSING_EN" ]; then
|
||||
echo "WARNING: Keys in ru.js but missing in en.js:"
|
||||
echo "$MISSING_EN"
|
||||
fi
|
||||
if [ -n "$MISSING_RU" ]; then
|
||||
echo "WARNING: Keys in en.js but missing in ru.js:"
|
||||
echo "$MISSING_RU"
|
||||
fi
|
||||
if [ -z "$MISSING_EN" ] && [ -z "$MISSING_RU" ]; then
|
||||
echo "OK: All locale keys match between ru.js and en.js"
|
||||
fi
|
||||
|
||||
exit $EXIT
|
||||
Loading…
Reference in New Issue