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:
mirivlad 2026-06-02 10:47:38 +08:00
parent 390d451977
commit 3089d777a8
20 changed files with 2658 additions and 1603 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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"
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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"

View File

@ -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
}

View File

@ -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
}

View File

@ -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',
}

View File

@ -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': 'Срок действия токена истёк',
}

View File

@ -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
}

92
internal/i18n/catalog.go Normal file
View File

@ -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
}

View File

@ -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"
}

View File

@ -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": "Срок действия токена истёк"
}

82
scripts/check-i18n.sh Executable file
View File

@ -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