Compare commits
No commits in common. "2fa583d157273aaabe4b5bb136b0b5cee1ab12be" and "50e7e958445f798f0ebdcfff6f2dd81ce22bef46" have entirely different histories.
2fa583d157
...
50e7e95844
10
build.sh
10
build.sh
|
|
@ -1,14 +1,4 @@
|
||||||
#!/bin/bash
|
#!/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 ..
|
cd frontend && npm run build && cd ..
|
||||||
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
|
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
|
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,52 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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/activity"
|
||||||
"verstak/internal/core/config"
|
"verstak/internal/core/config"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/nodes"
|
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
|
"verstak/internal/core/nodes"
|
||||||
"verstak/internal/core/plugins"
|
"verstak/internal/core/plugins"
|
||||||
"verstak/internal/core/search"
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
|
|
@ -80,11 +80,11 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
err = wails.Run(&options.App{
|
err = wails.Run(&options.App{
|
||||||
Title: "Верстак",
|
Title: "Верстак",
|
||||||
Width: 1280,
|
Width: 1280,
|
||||||
Height: 800,
|
Height: 800,
|
||||||
MinWidth: 800,
|
MinWidth: 800,
|
||||||
MinHeight: 600,
|
MinHeight: 600,
|
||||||
BackgroundColour: &options.RGBA{R: 19, G: 19, B: 31, A: 1},
|
BackgroundColour: &options.RGBA{R: 19, G: 19, B: 31, A: 1},
|
||||||
AssetServer: &assetserver.Options{
|
AssetServer: &assetserver.Options{
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
|
|
|
||||||
|
|
@ -1,467 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
var passwordRE = regexp.MustCompile(`^[A-Za-z0-9]+$`)
|
|
||||||
|
|
||||||
type AdminUser struct {
|
|
||||||
Username string `yaml:"username"`
|
|
||||||
PasswordHash string `yaml:"password_hash"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Port int `yaml:"port"`
|
|
||||||
Admin []AdminUser `yaml:"admin"`
|
|
||||||
mu sync.Mutex
|
|
||||||
path string
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadConfig(dataDir string) (*Config, error) {
|
|
||||||
path := filepath.Join(dataDir, "config.yml")
|
|
||||||
cfg := &Config{
|
|
||||||
Port: 47732,
|
|
||||||
Admin: nil,
|
|
||||||
path: path,
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err == nil {
|
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse config: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) Save() error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
data, err := yaml.Marshal(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(c.path, data, 0640)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) SetAdmin(username, password string) error {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
user := AdminUser{Username: username, PasswordHash: string(hash)}
|
|
||||||
// Replace existing or append.
|
|
||||||
for i, u := range c.Admin {
|
|
||||||
if u.Username == username {
|
|
||||||
c.Admin[i] = user
|
|
||||||
return c.saveLocked()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Admin = append(c.Admin, user)
|
|
||||||
return c.saveLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) CheckAdmin(username, password string) bool {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
for _, u := range c.Admin {
|
|
||||||
if u.Username == username {
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) saveLocked() error {
|
|
||||||
data, err := yaml.Marshal(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(c.path, data, 0640)
|
|
||||||
}
|
|
||||||
|
|
@ -1,501 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(adminLoginHTML("ru")))
|
|
||||||
case "POST":
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
jsonErr(w, 400, "bad form")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user := r.FormValue("username")
|
|
||||||
pass := r.FormValue("password")
|
|
||||||
if !s.cfg.CheckAdmin(user, pass) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(401)
|
|
||||||
w.Write([]byte("<html><body><h1>401 Unauthorized</h1><a href='/admin/login'>Try again</a></body></html>"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tok := s.tokens.Create()
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "session", Value: tok, Path: "/admin",
|
|
||||||
HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
||||||
MaxAge: 86400,
|
|
||||||
})
|
|
||||||
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireAdmin(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
// Fetch data for dashboard.
|
|
||||||
var deviceCount, opsCount int
|
|
||||||
s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount)
|
|
||||||
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
|
|
||||||
|
|
||||||
// Load SMTP config for display.
|
|
||||||
smtpHost := s.smtpGet("smtp_host")
|
|
||||||
smtpPort := s.smtpGet("smtp_port")
|
|
||||||
smtpUser := s.smtpGet("smtp_user")
|
|
||||||
smtpFrom := s.smtpGet("smtp_from")
|
|
||||||
smtpSecurity := s.smtpGet("smtp_security")
|
|
||||||
srvURL := s.smtpGet("server_url")
|
|
||||||
|
|
||||||
html := `<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Verstak Sync — Admin</title>
|
|
||||||
<style>
|
|
||||||
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:860px;margin:0 auto}
|
|
||||||
a{color:#6366f1}
|
|
||||||
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
|
|
||||||
h2{margin-top:24px;font-size:16px}
|
|
||||||
.stat{background:#1a1a28;border:1px solid #2a2a3c;padding:12px 16px;border-radius:8px;margin:8px 0}
|
|
||||||
table{width:100%%;border-collapse:collapse;margin-top:8px}
|
|
||||||
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
|
|
||||||
th{font-size:12px;color:#888;text-transform:uppercase}
|
|
||||||
.key-cell{max-width:360px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0}
|
|
||||||
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
|
|
||||||
.btn:hover{background:#222233}
|
|
||||||
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
|
|
||||||
.btn-primary:hover{background:#4f46e5}
|
|
||||||
.btn-danger{color:#ff6b6b;border-color:#4a2222}
|
|
||||||
.btn-danger:hover{background:#3a2222}
|
|
||||||
.copy-btn{padding:2px 8px;font-size:11px;margin-left:6px}
|
|
||||||
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;margin:0;box-sizing:border-box}
|
|
||||||
input:focus{outline:none;border-color:#6366f1}
|
|
||||||
.form-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
|
|
||||||
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
|
|
||||||
.form-row input{flex:1}
|
|
||||||
.toolbar{display:flex;gap:8px;margin:16px 0;flex-wrap:wrap}
|
|
||||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
|
|
||||||
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:420px;max-width:90vw;position:relative;max-height:80vh;overflow-y:auto}
|
|
||||||
.modal h2{margin-top:0}
|
|
||||||
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
|
|
||||||
.modal-close:hover{color:#e4e4ef}
|
|
||||||
pre{background:#13131f;border:1px solid #2a2a3c;border-radius:8px;padding:12px;overflow-x:auto;white-space:pre-wrap}
|
|
||||||
</style>
|
|
||||||
</head><body>
|
|
||||||
<h1>Verstak Sync Server</h1>
|
|
||||||
<div style="display:flex;gap:20px;flex-wrap:wrap">
|
|
||||||
<div class="stat" style="margin:0"><strong>Устройств:</strong> <span id="dev-count">0</span></div>
|
|
||||||
<div class="stat" style="margin:0"><strong>Операций:</strong> <span id="op-count">0</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
|
||||||
<button class="btn btn-primary" onclick="openSMTP()">Настройка SMTP</button>
|
|
||||||
<a href="/admin/users" style="text-decoration:none"><button class="btn" type="button">Пользователи</button></a>
|
|
||||||
<button class="btn" onclick="openHealth()">Health check</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Устройства</h2>
|
|
||||||
<div id="devices"></div>
|
|
||||||
<script>
|
|
||||||
fetch('/admin/api/devices').then(r=>r.json()).then(devices=>{
|
|
||||||
const div=document.getElementById('devices')
|
|
||||||
if(!devices.length){div.innerHTML='<p>Нет устройств</p>';return}
|
|
||||||
div.innerHTML='<table><tr><th>Устройство</th><th>Пользователь</th><th>Версия</th><th>Статус</th><th>Активность</th><th></th></tr>'+
|
|
||||||
devices.map(d=>{
|
|
||||||
var status=d.revoked_at?'<span style="color:#ff6b6b">Отозвано</span>':'<span style="color:#34d399">Активно</span>'
|
|
||||||
var ls=d.last_seen||'—'
|
|
||||||
var revBtn=''
|
|
||||||
if(!d.revoked_at) revBtn='<button class="btn btn-danger" onclick="revokeDevice(\''+d.id+'\')">Отозвать</button>'
|
|
||||||
return '<tr><td>'+d.name+'</td><td>'+(d.user||'—')+'</td><td>'+(d.client_version||'—')+'</td><td>'+status+'</td><td>'+ls+'</td><td>'+revBtn+'</td></tr>'
|
|
||||||
}).join('')+'</table>'
|
|
||||||
document.getElementById('dev-count').textContent=devices.length
|
|
||||||
})
|
|
||||||
fetch('/admin/api/stats').then(r=>r.json()).then(stats=>{
|
|
||||||
document.getElementById('op-count').textContent=stats.ops||'0'
|
|
||||||
})
|
|
||||||
function revokeDevice(id){
|
|
||||||
if(!confirm('Отозвать устройство?'))return
|
|
||||||
fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())
|
|
||||||
}
|
|
||||||
function openSMTP(){document.getElementById('smtp-modal').style.display='flex';document.getElementById('smtp-test-result').textContent=''}
|
|
||||||
function closeSMTP(e){if(!e||e.target.id==='smtp-modal')document.getElementById('smtp-modal').style.display='none'}
|
|
||||||
function openHealth(){var m=document.getElementById('health-modal');m.style.display='flex';document.getElementById('health-result').textContent='Загрузка...';fetch('/api/v1/health').then(function(r){return r.text()}).then(function(t){document.getElementById('health-result').textContent=t})}
|
|
||||||
function closeHealth(e){if(!e||e.target.id==='health-modal')document.getElementById('health-modal').style.display='none'}
|
|
||||||
function testSMTP(){
|
|
||||||
var f=document.querySelector('#smtp-modal form')
|
|
||||||
var fd=new FormData(f)
|
|
||||||
var obj={};for(var e of fd.entries()){obj[e[0]]=e[1]}
|
|
||||||
var r=document.getElementById('smtp-test-result')
|
|
||||||
r.textContent='⏳ Тестируем...';r.style.color='#888'
|
|
||||||
fetch('/admin/api/smtp/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)}).then(function(r2){return r2.json()}).then(function(d){
|
|
||||||
r.textContent=d.ok?'✓ Тест пройден':'✗ '+d.error
|
|
||||||
r.style.color=d.ok?'#4ade80':'#ff6b6b'
|
|
||||||
}).catch(function(e){r.textContent='✗ '+e;r.style.color='#ff6b6b'})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div id="smtp-modal" class="modal-overlay" style="display:none" onclick="closeSMTP(event)">
|
|
||||||
<div class="modal">
|
|
||||||
<button class="modal-close" onclick="closeSMTP()">×</button>
|
|
||||||
<h2>SMTP (для писем)</h2>
|
|
||||||
<form action="/admin/api/smtp" method="POST">
|
|
||||||
<div class="form-row"><label>Сервер</label><input name="smtp_host" value="` + smtpHost + `" placeholder="smtp.example.com"></div>
|
|
||||||
<div class="form-row"><label>Порт</label><input name="smtp_port" value="` + smtpPort + `" placeholder="587"></div>
|
|
||||||
<div class="form-row"><label>Тип</label><select name="smtp_security" style="font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;flex:1;box-sizing:border-box">
|
|
||||||
<option value="starttls"` + sel(smtpSecurity, "starttls") + `>STARTTLS</option>
|
|
||||||
<option value="tls"` + sel(smtpSecurity, "tls") + `>TLS</option>
|
|
||||||
<option value="none"` + sel(smtpSecurity, "none") + `>Без шифрования</option>
|
|
||||||
</select></div>
|
|
||||||
<div class="form-row"><label>Логин</label><input name="smtp_user" value="` + smtpUser + `" placeholder="user@example.com"></div>
|
|
||||||
<div class="form-row"><label>Пароль</label><input type="password" name="smtp_pass" placeholder="••••••••"></div>
|
|
||||||
<div class="form-row"><label>От кого</label><input name="smtp_from" value="` + smtpFrom + `" placeholder="noreply@example.com"></div>
|
|
||||||
<div class="form-row"><label>URL сервера</label><input name="server_url" value="` + srvURL + `" placeholder="https://example.com:47732"></div>
|
|
||||||
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
|
|
||||||
<button class="btn btn-primary">Сохранить SMTP</button>
|
|
||||||
<button class="btn" type="button" onclick="testSMTP()">Test</button>
|
|
||||||
<span id="smtp-test-result" style="font-size:12px"></span>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="health-modal" class="modal-overlay" style="display:none" onclick="closeHealth(event)">
|
|
||||||
<div class="modal">
|
|
||||||
<button class="modal-close" onclick="closeHealth()">×</button>
|
|
||||||
<h2>Health check</h2>
|
|
||||||
<pre id="health-result">Загрузка...</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
_ = smtpURL
|
|
||||||
_ = smtpUser
|
|
||||||
_ = smtpFrom
|
|
||||||
_ = smtpSecurity
|
|
||||||
_ = smtpHost
|
|
||||||
_ = smtpPort
|
|
||||||
|
|
||||||
</body></html>`
|
|
||||||
w.Write([]byte(html))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireAdmin(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(adminUsersHTML("ru")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireAdmin(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var opsCount int
|
|
||||||
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
|
|
||||||
jsonOK(w, map[string]int{"ops": opsCount})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireAdmin(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Host string `json:"smtp_host"`
|
|
||||||
Port string `json:"smtp_port"`
|
|
||||||
User string `json:"smtp_user"`
|
|
||||||
Pass string `json:"smtp_pass"`
|
|
||||||
Security string `json:"smtp_security"`
|
|
||||||
From string `json:"smtp_from"`
|
|
||||||
To string `json:"test_to"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "bad json")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
host := req.Host
|
|
||||||
port := req.Port
|
|
||||||
user := req.User
|
|
||||||
pass := req.Pass
|
|
||||||
security := req.Security
|
|
||||||
from := req.From
|
|
||||||
to := req.To
|
|
||||||
if to == "" {
|
|
||||||
to = from
|
|
||||||
}
|
|
||||||
if host == "" || port == "" || from == "" {
|
|
||||||
jsonOK(w, map[string]interface{}{"ok": false, "error": "host, port and from required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.smtpTest(host, port, user, pass, security, from, to); err != nil {
|
|
||||||
jsonOK(w, map[string]interface{}{"ok": false, "error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]interface{}{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireAdmin(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case path == "/api/devices" && r.Method == "GET":
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at,
|
|
||||||
COALESCE(u.username,'')
|
|
||||||
FROM server_devices d
|
|
||||||
LEFT JOIN server_users u ON u.id = d.user_id
|
|
||||||
ORDER BY d.created_at DESC`)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
type devDTO struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ClientVersion string `json:"client_version"`
|
|
||||||
LastSeen string `json:"last_seen"`
|
|
||||||
RevokedAt string `json:"revoked_at"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
User string `json:"user"`
|
|
||||||
}
|
|
||||||
var out []devDTO
|
|
||||||
for rows.Next() {
|
|
||||||
var d devDTO
|
|
||||||
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt, &d.User)
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
jsonOK(w, out)
|
|
||||||
|
|
||||||
case path == "/api/keys" && r.Method == "GET":
|
|
||||||
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []map[string]string
|
|
||||||
for rows.Next() {
|
|
||||||
var id, name, key string
|
|
||||||
rows.Scan(&id, &name, &key)
|
|
||||||
out = append(out, map[string]string{"id": id, "name": name, "api_key": key})
|
|
||||||
}
|
|
||||||
jsonOK(w, out)
|
|
||||||
|
|
||||||
case path == "/api/keys" && r.Method == "POST":
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
jsonErr(w, 400, "bad form")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := r.FormValue("name")
|
|
||||||
if name == "" {
|
|
||||||
jsonErr(w, 400, "name required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b := make([]byte, 20)
|
|
||||||
rand.Read(b)
|
|
||||||
apiKey := hex.EncodeToString(b)
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
apiKey[:12], name, apiKey, now, now,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
|
||||||
|
|
||||||
case strings.HasPrefix(path, "/api/keys/") && r.Method == "DELETE":
|
|
||||||
id := strings.TrimPrefix(path, "/api/keys/")
|
|
||||||
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
|
|
||||||
jsonOK(w, map[string]string{"status": "deleted"})
|
|
||||||
|
|
||||||
case path == "/api/smtp" && r.Method == "POST":
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
jsonErr(w, 400, "bad form")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
|
|
||||||
val := r.FormValue(key)
|
|
||||||
if val != "" {
|
|
||||||
s.smtpSet(key, val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
|
||||||
|
|
||||||
case path == "/api/users" && r.Method == "GET":
|
|
||||||
filter := r.URL.Query().Get("filter")
|
|
||||||
sort := r.URL.Query().Get("sort")
|
|
||||||
order := r.URL.Query().Get("order")
|
|
||||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
||||||
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if perPage < 1 || perPage > 100 {
|
|
||||||
perPage = 20
|
|
||||||
}
|
|
||||||
where := ""
|
|
||||||
var args []interface{}
|
|
||||||
if filter != "" {
|
|
||||||
where = " WHERE u.username LIKE ?"
|
|
||||||
args = append(args, "%"+filter+"%")
|
|
||||||
}
|
|
||||||
validSorts := map[string]string{
|
|
||||||
"username": "u.username",
|
|
||||||
"email": "u.email",
|
|
||||||
"confirmed": "u.confirmed",
|
|
||||||
"blocked": "u.blocked",
|
|
||||||
"created_at": "u.created_at",
|
|
||||||
"last_seen": "u.last_seen",
|
|
||||||
"devices": "devices",
|
|
||||||
}
|
|
||||||
orderClause := "u.created_at DESC"
|
|
||||||
if col, ok := validSorts[sort]; ok {
|
|
||||||
if order != "asc" {
|
|
||||||
order = "desc"
|
|
||||||
}
|
|
||||||
orderClause = col + " " + order
|
|
||||||
}
|
|
||||||
// Count total.
|
|
||||||
var total int
|
|
||||||
countSQL := "SELECT COUNT(*) FROM server_users u" + where
|
|
||||||
s.db.QueryRow(countSQL, args...).Scan(&total)
|
|
||||||
// Fetch page.
|
|
||||||
offset := (page - 1) * perPage
|
|
||||||
sql := `SELECT u.id, u.username, u.email, u.confirmed, u.blocked, u.last_seen, u.created_at,
|
|
||||||
COALESCE((SELECT COUNT(*) FROM server_user_devices ud JOIN server_devices d ON d.id=ud.device_id WHERE ud.user_id=u.id),0) AS devices
|
|
||||||
FROM server_users u` + where + ` ORDER BY ` + orderClause + ` LIMIT ? OFFSET ?`
|
|
||||||
args = append(args, perPage, offset)
|
|
||||||
rows, err := s.db.Query(sql, args...)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
type userRow struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Confirmed int `json:"confirmed"`
|
|
||||||
Blocked int `json:"blocked"`
|
|
||||||
LastSeen string `json:"last_seen"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
Devices int `json:"devices"`
|
|
||||||
}
|
|
||||||
var users []userRow
|
|
||||||
for rows.Next() {
|
|
||||||
var u userRow
|
|
||||||
var lastSeen *string
|
|
||||||
rows.Scan(&u.ID, &u.Username, &u.Email, &u.Confirmed, &u.Blocked, &lastSeen, &u.CreatedAt, &u.Devices)
|
|
||||||
if lastSeen != nil {
|
|
||||||
u.LastSeen = *lastSeen
|
|
||||||
}
|
|
||||||
users = append(users, u)
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]interface{}{
|
|
||||||
"users": users,
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"per_page": perPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
case strings.HasPrefix(path, "/api/users/") && r.Method == "POST":
|
|
||||||
sub := strings.TrimPrefix(path, "/api/users/")
|
|
||||||
if strings.HasSuffix(sub, "/block") {
|
|
||||||
id := strings.TrimSuffix(sub, "/block")
|
|
||||||
id = strings.TrimSuffix(id, "/")
|
|
||||||
var blocked int
|
|
||||||
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked)
|
|
||||||
newVal := 1
|
|
||||||
if blocked != 0 {
|
|
||||||
newVal = 0
|
|
||||||
}
|
|
||||||
s.db.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id)
|
|
||||||
jsonOK(w, map[string]interface{}{"status": "ok", "blocked": newVal})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(sub, "/reset-password") {
|
|
||||||
id := strings.TrimSuffix(sub, "/reset-password")
|
|
||||||
id = strings.TrimSuffix(id, "/")
|
|
||||||
b := make([]byte, 12)
|
|
||||||
rand.Read(b)
|
|
||||||
newPass := hex.EncodeToString(b)
|
|
||||||
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
|
|
||||||
_, err := s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]interface{}{"status": "ok", "new_password": newPass})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(sub, "/edit") {
|
|
||||||
id := strings.TrimSuffix(sub, "/edit")
|
|
||||||
id = strings.TrimSuffix(id, "/")
|
|
||||||
var req struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "bad json")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Username == "" || req.Email == "" {
|
|
||||||
jsonErr(w, 400, "username and email required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", req.Username, strings.ToLower(req.Email), id)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]interface{}{"status": "ok"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonErr(w, 404, "unknown action")
|
|
||||||
|
|
||||||
case strings.HasPrefix(path, "/api/users/") && r.Method == "DELETE":
|
|
||||||
id := strings.TrimPrefix(path, "/api/users/")
|
|
||||||
id = strings.TrimSuffix(id, "/")
|
|
||||||
// Get user devices to delete.
|
|
||||||
rows, _ := s.db.Query("SELECT device_id FROM server_user_devices WHERE user_id=?", id)
|
|
||||||
var deviceIDs []string
|
|
||||||
for rows.Next() {
|
|
||||||
var did string
|
|
||||||
rows.Scan(&did)
|
|
||||||
deviceIDs = append(deviceIDs, did)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
for _, did := range deviceIDs {
|
|
||||||
s.db.Exec("DELETE FROM server_devices WHERE id=?", did)
|
|
||||||
}
|
|
||||||
s.db.Exec("DELETE FROM server_user_devices WHERE user_id=?", id)
|
|
||||||
s.db.Exec("DELETE FROM server_email_tokens WHERE user_id=?", id)
|
|
||||||
s.db.Exec("DELETE FROM server_users WHERE id=?", id)
|
|
||||||
jsonOK(w, map[string]interface{}{"status": "deleted"})
|
|
||||||
|
|
||||||
default:
|
|
||||||
jsonErr(w, 404, "not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/" {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
w.Write([]byte("Verstak Sync Server\n"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonErr(w, 404, "not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
||||||
jsonOK(w, map[string]interface{}{
|
|
||||||
"status": "ok",
|
|
||||||
"version": "verstak-server/v1",
|
|
||||||
"time": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
jsonErr(w, 405, "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ip := r.RemoteAddr
|
|
||||||
if idx := strings.LastIndex(ip, ":"); idx >= 0 {
|
|
||||||
ip = ip[:idx]
|
|
||||||
}
|
|
||||||
if !s.pairLimit.allow(ip) {
|
|
||||||
s.auditLog("rate_limit_exceeded", "", "", ip, "pair rate limit exceeded")
|
|
||||||
jsonErr(w, 429, "too many attempts")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Login string `json:"login"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
DeviceName string `json:"device_name"`
|
|
||||||
ClientVersion string `json:"client_version"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "bad json")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Login == "" || req.Password == "" {
|
|
||||||
jsonErr(w, 400, "login and password required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.DeviceName == "" {
|
|
||||||
req.DeviceName = "unknown"
|
|
||||||
}
|
|
||||||
// Look up user.
|
|
||||||
var userID, hash string
|
|
||||||
var confirmed, blocked int
|
|
||||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
|
||||||
req.Login, strings.ToLower(req.Login)).Scan(&userID, &hash, &confirmed, &blocked)
|
|
||||||
if err != nil {
|
|
||||||
s.auditLog("device_auth_failed", "", "", ip, "pair: user not found: "+req.Login)
|
|
||||||
jsonErr(w, 401, "invalid credentials")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if blocked != 0 {
|
|
||||||
s.auditLog("device_auth_failed", userID, "", ip, "pair: user blocked")
|
|
||||||
jsonErr(w, 403, "account blocked")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if confirmed == 0 {
|
|
||||||
s.auditLog("device_auth_failed", userID, "", ip, "pair: email not confirmed")
|
|
||||||
jsonErr(w, 403, "email not confirmed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
|
||||||
s.auditLog("device_auth_failed", userID, "", ip, "pair: wrong password")
|
|
||||||
jsonErr(w, 401, "invalid credentials")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Generate device.
|
|
||||||
devID := make([]byte, 12)
|
|
||||||
rand.Read(devID)
|
|
||||||
deviceID := "dev_" + hex.EncodeToString(devID)
|
|
||||||
token, prefix, suffix := genDeviceToken()
|
|
||||||
tokenHash := sha256Hex(token)
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
apiKey := make([]byte, 20)
|
|
||||||
rand.Read(apiKey)
|
|
||||||
_, err = s.db.Exec(`INSERT INTO server_devices
|
|
||||||
(id, name, api_key, token_hash, token_prefix, token_suffix, user_id, client_version, last_ip, last_seen, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
|
|
||||||
userID, req.ClientVersion, ip, now, now)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
|
|
||||||
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID)
|
|
||||||
s.pairLimit.reset(ip)
|
|
||||||
s.auditLog("device_paired", userID, deviceID, ip, "device paired: "+req.DeviceName)
|
|
||||||
jsonOK(w, map[string]interface{}{
|
|
||||||
"user_id": userID,
|
|
||||||
"device_id": deviceID,
|
|
||||||
"device_token": token,
|
|
||||||
"server_time": now,
|
|
||||||
"initial_sync_cursor": 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAuthTest(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
jsonErr(w, 405, "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "bad json")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Username == "" || req.Password == "" {
|
|
||||||
jsonErr(w, 400, "username and password required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var hash string
|
|
||||||
var confirmed, blocked int
|
|
||||||
err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
|
||||||
req.Username, strings.ToLower(req.Username)).Scan(&hash, &confirmed, &blocked)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 401, "invalid credentials")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if blocked != 0 {
|
|
||||||
jsonErr(w, 403, "account blocked")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if confirmed == 0 {
|
|
||||||
jsonErr(w, 403, "email not confirmed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
|
||||||
jsonErr(w, 401, "invalid credentials")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
jsonErr(w, 405, "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
||||||
if tok == "" {
|
|
||||||
jsonErr(w, 401, "token required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash := sha256Hex(tok)
|
|
||||||
var deviceID, userID string
|
|
||||||
err := s.db.QueryRow("SELECT id, user_id FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 401, "invalid token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, deviceID)
|
|
||||||
s.auditLog("device_revoked", userID, deviceID, r.RemoteAddr, "device revoked by user")
|
|
||||||
jsonOK(w, map[string]string{"status": "revoked"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
jsonErr(w, 405, "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, ok := s.requireUserWeb(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
DeviceID string `json:"device_id"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "invalid JSON")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.DeviceID == "" || req.Password == "" {
|
|
||||||
jsonErr(w, 400, "device_id and password required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Verify password.
|
|
||||||
var pwHash string
|
|
||||||
err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", userID).Scan(&pwHash)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 403, "access denied")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(req.Password)) != nil {
|
|
||||||
jsonErr(w, 403, "wrong password")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Verify device belongs to user.
|
|
||||||
var devUserID string
|
|
||||||
err = s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", req.DeviceID).Scan(&devUserID)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 404, "device not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if devUserID != userID {
|
|
||||||
jsonErr(w, 403, "device does not belong to you")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, req.DeviceID)
|
|
||||||
s.auditLog("device_revoked", userID, req.DeviceID, r.RemoteAddr, "device revoked via web dashboard")
|
|
||||||
jsonOK(w, map[string]string{"status": "revoked"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleClientMe(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
||||||
if tok == "" {
|
|
||||||
jsonErr(w, 401, "token required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash := sha256Hex(tok)
|
|
||||||
var deviceID, userID, name, clientVer, lastSeen, revokedAt, createdAt string
|
|
||||||
err := s.db.QueryRow(`SELECT d.id, d.user_id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
|
|
||||||
FROM server_devices d WHERE d.token_hash=?`, hash).
|
|
||||||
Scan(&deviceID, &userID, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 401, "invalid token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var username string
|
|
||||||
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
|
|
||||||
jsonOK(w, map[string]interface{}{
|
|
||||||
"device_id": deviceID,
|
|
||||||
"user_id": userID,
|
|
||||||
"username": username,
|
|
||||||
"device_name": name,
|
|
||||||
"client_version": clientVer,
|
|
||||||
"last_seen": lastSeen,
|
|
||||||
"revoked_at": revokedAt,
|
|
||||||
"created_at": createdAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
jsonErr(w, 405, "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "invalid JSON")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Name == "" {
|
|
||||||
jsonErr(w, 400, "name required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Username == "" || req.Password == "" {
|
|
||||||
jsonErr(w, 401, "username and password required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up user by username or email.
|
|
||||||
var userID, hash string
|
|
||||||
var confirmed, blocked int
|
|
||||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
|
||||||
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 401, "invalid credentials")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if blocked != 0 {
|
|
||||||
jsonErr(w, 403, "account blocked")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if confirmed == 0 {
|
|
||||||
jsonErr(w, 403, "email not confirmed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
|
||||||
jsonErr(w, 401, "invalid credentials")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
b := make([]byte, 20)
|
|
||||||
rand.Read(b)
|
|
||||||
apiKey := hex.EncodeToString(b)
|
|
||||||
deviceID := apiKey[:12]
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
|
|
||||||
_, err = s.db.Exec(
|
|
||||||
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
deviceID, req.Name, apiKey, now, now,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Link device to user.
|
|
||||||
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
|
|
||||||
|
|
||||||
jsonOK(w, map[string]interface{}{
|
|
||||||
"device_id": deviceID,
|
|
||||||
"api_key": apiKey,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,539 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
jsonErr(w, 405, "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "invalid JSON")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Username == "" || req.Email == "" || req.Password == "" {
|
|
||||||
jsonErr(w, 400, "username, email and password required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validatePassword(req.Password); err != "" {
|
|
||||||
jsonErr(w, 400, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
|
||||||
jsonErr(w, 400, "invalid email")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, "internal error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
id := make([]byte, 12)
|
|
||||||
rand.Read(id)
|
|
||||||
userID := hex.EncodeToString(id)
|
|
||||||
_, err = s.db.Exec(
|
|
||||||
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
|
|
||||||
userID, req.Username, strings.ToLower(req.Email), string(hash), now,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
|
||||||
jsonErr(w, 409, "username or email already taken")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Confirmation token.
|
|
||||||
tok := make([]byte, 24)
|
|
||||||
rand.Read(tok)
|
|
||||||
tokenStr := hex.EncodeToString(tok)
|
|
||||||
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
|
||||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
|
|
||||||
tokenStr, userID, exp, now)
|
|
||||||
// Try to send email.
|
|
||||||
host := s.smtpGet("smtp_host")
|
|
||||||
if host != "" {
|
|
||||||
srvURL := s.smtpGet("server_url")
|
|
||||||
var confirmURL string
|
|
||||||
if srvURL != "" {
|
|
||||||
confirmURL = fmt.Sprintf("%s/confirm?token=%s", srvURL, tokenStr)
|
|
||||||
} else {
|
|
||||||
confirmURL = fmt.Sprintf("/api/v1/auth/confirm?token=%s", tokenStr)
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf("Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.", confirmURL)
|
|
||||||
if err := s.smtpSend(req.Email, "Confirm your Verstak Sync account", body); err != nil {
|
|
||||||
log.Printf("register: failed to send confirm email: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Printf("register: SMTP not configured, confirmation token=%s for user %s", tokenStr, req.Username)
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]string{"status": "confirmation_sent"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "GET" {
|
|
||||||
jsonErr(w, 405, "GET required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tokenStr := r.URL.Query().Get("token")
|
|
||||||
if tokenStr == "" {
|
|
||||||
jsonErr(w, 400, "token required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var userID, expiresAt string
|
|
||||||
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='confirm'",
|
|
||||||
tokenStr).Scan(&userID, &expiresAt)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 400, "invalid or expired token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
|
||||||
if err != nil || time.Now().After(exp) {
|
|
||||||
jsonErr(w, 400, "token expired")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID)
|
|
||||||
log.Printf("confirm: user %s confirmed email", userID)
|
|
||||||
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", tokenStr)
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(confirmedHTML("ru")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
jsonErr(w, 405, "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "invalid JSON")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Username == "" || req.Password == "" {
|
|
||||||
jsonErr(w, 400, "username and password required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var userID, hash string
|
|
||||||
var confirmed, blocked int
|
|
||||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
|
||||||
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 401, "invalid credentials")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if blocked != 0 {
|
|
||||||
jsonErr(w, 403, "account blocked")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if confirmed == 0 {
|
|
||||||
jsonErr(w, 403, "email not confirmed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
|
||||||
jsonErr(w, 401, "invalid credentials")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID)
|
|
||||||
tok := s.userTokens.Create(userID)
|
|
||||||
jsonOK(w, map[string]string{"token": tok, "user_id": userID})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
jsonErr(w, 405, "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "invalid JSON")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Email == "" {
|
|
||||||
jsonErr(w, 400, "email required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var userID string
|
|
||||||
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", strings.ToLower(req.Email)).Scan(&userID)
|
|
||||||
if err != nil {
|
|
||||||
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tok := make([]byte, 24)
|
|
||||||
rand.Read(tok)
|
|
||||||
tokenStr := hex.EncodeToString(tok)
|
|
||||||
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
|
|
||||||
tokenStr, userID, exp, now)
|
|
||||||
host := s.smtpGet("smtp_host")
|
|
||||||
if host != "" {
|
|
||||||
srvURL := s.smtpGet("server_url")
|
|
||||||
resetURL := fmt.Sprintf("/api/v1/auth/reset?token=%s", tokenStr)
|
|
||||||
if srvURL != "" {
|
|
||||||
resetURL = fmt.Sprintf("%s/api/v1/auth/reset?token=%s", srvURL, tokenStr)
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf("Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.", resetURL)
|
|
||||||
s.smtpSend(req.Email, "Verstak Sync password reset", body)
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
jsonErr(w, 405, "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
NewPassword string `json:"new_password"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "invalid JSON")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Token == "" || req.NewPassword == "" {
|
|
||||||
jsonErr(w, 400, "token and new_password required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validatePassword(req.NewPassword); err != "" {
|
|
||||||
jsonErr(w, 400, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var userID, expiresAt string
|
|
||||||
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
|
|
||||||
req.Token).Scan(&userID, &expiresAt)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 400, "invalid or expired token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
|
||||||
if err != nil || time.Now().After(exp) {
|
|
||||||
jsonErr(w, 400, "token expired")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, "internal error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
|
|
||||||
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", req.Token)
|
|
||||||
jsonOK(w, map[string]string{"status": "password reset"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID, ok := s.requireUser(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != "GET" {
|
|
||||||
jsonErr(w, 405, "GET required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT d.id, d.name, d.last_seen, d.created_at
|
|
||||||
FROM server_devices d
|
|
||||||
JOIN server_user_devices ud ON ud.device_id = d.id
|
|
||||||
WHERE ud.user_id = ?
|
|
||||||
ORDER BY d.created_at`, userID)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
type deviceDTO struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
LastSeen string `json:"last_seen"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
var devices []deviceDTO
|
|
||||||
for rows.Next() {
|
|
||||||
var d deviceDTO
|
|
||||||
var lastSeen sql.NullString
|
|
||||||
if err := rows.Scan(&d.ID, &d.Name, &lastSeen, &d.CreatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
d.LastSeen = lastSeen.String
|
|
||||||
devices = append(devices, d)
|
|
||||||
}
|
|
||||||
if devices == nil {
|
|
||||||
devices = []deviceDTO{}
|
|
||||||
}
|
|
||||||
jsonOK(w, map[string]interface{}{"devices": devices})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireAPIKey(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != "POST" {
|
|
||||||
jsonErr(w, 405, "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
DeviceID string `json:"device_id"`
|
|
||||||
IdempotencyKey string `json:"idempotency_key"`
|
|
||||||
Ops []struct {
|
|
||||||
OpID string `json:"op_id"`
|
|
||||||
EntityType string `json:"entity_type"`
|
|
||||||
EntityID string `json:"entity_id"`
|
|
||||||
OpType string `json:"op_type"`
|
|
||||||
PayloadJSON string `json:"payload_json"`
|
|
||||||
ClientSequence int `json:"client_sequence"`
|
|
||||||
LastSeenServerSeq int `json:"last_seen_server_seq"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
} `json:"ops"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "invalid JSON: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotency: if request-level key provided, check for cached response.
|
|
||||||
if req.IdempotencyKey != "" {
|
|
||||||
var cachedJSON string
|
|
||||||
err := s.db.QueryRow("SELECT response_json FROM server_idempotency_keys WHERE idempotency_key=?", req.IdempotencyKey).Scan(&cachedJSON)
|
|
||||||
if err == nil {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(cachedJSON))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
var accepted []string
|
|
||||||
var conflicts []map[string]interface{}
|
|
||||||
|
|
||||||
for _, op := range req.Ops {
|
|
||||||
if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Conflict detection: check if another device already created ops for this entity
|
|
||||||
// with a server_sequence higher than what this client last saw.
|
|
||||||
if op.LastSeenServerSeq > 0 {
|
|
||||||
conflictRows, err := s.db.Query(`
|
|
||||||
SELECT op_id, device_id, op_type, server_sequence FROM server_ops
|
|
||||||
WHERE entity_type=? AND entity_id=? AND device_id!=?
|
|
||||||
AND server_sequence > ? AND op_type != 'delete'
|
|
||||||
ORDER BY server_sequence`, op.EntityType, op.EntityID, req.DeviceID, op.LastSeenServerSeq)
|
|
||||||
if err == nil {
|
|
||||||
for conflictRows.Next() {
|
|
||||||
var cOpID, cDevID, cOpType string
|
|
||||||
var cSeq int
|
|
||||||
conflictRows.Scan(&cOpID, &cDevID, &cOpType, &cSeq)
|
|
||||||
conflicts = append(conflicts, map[string]interface{}{
|
|
||||||
"op_id": cOpID,
|
|
||||||
"device_id": cDevID,
|
|
||||||
"op_type": cOpType,
|
|
||||||
"server_sequence": cSeq,
|
|
||||||
"entity_type": op.EntityType,
|
|
||||||
"entity_id": op.EntityID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
conflictRows.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := s.db.Exec(
|
|
||||||
`INSERT OR IGNORE INTO server_ops (op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at)
|
|
||||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON,
|
|
||||||
req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
continue // duplicate op_id
|
|
||||||
}
|
|
||||||
seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, req.DeviceID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seq, _ := seqRes.LastInsertId()
|
|
||||||
s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID)
|
|
||||||
|
|
||||||
if op.OpType == "delete" {
|
|
||||||
s.db.Exec(`INSERT OR REPLACE INTO server_tombstones (entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?)`,
|
|
||||||
op.EntityType, op.EntityID, op.OpID, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
accepted = append(accepted, op.OpID)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"accepted": accepted,
|
|
||||||
"count": len(accepted),
|
|
||||||
"conflicts": conflicts,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache response for idempotency.
|
|
||||||
if req.IdempotencyKey != "" {
|
|
||||||
if respJSON, err := json.Marshal(resp); err == nil {
|
|
||||||
s.db.Exec("INSERT OR IGNORE INTO server_idempotency_keys (idempotency_key, response_json, created_at) VALUES (?, ?, ?)",
|
|
||||||
req.IdempotencyKey, string(respJSON), now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonOK(w, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireAPIKey(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != "POST" {
|
|
||||||
jsonErr(w, 405, "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
SinceSequence int `json:"since_sequence"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
jsonErr(w, 400, "invalid JSON")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var serverSeq int
|
|
||||||
s.db.QueryRow("SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops").Scan(&serverSeq)
|
|
||||||
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at
|
|
||||||
FROM server_ops
|
|
||||||
WHERE server_sequence > ? AND server_sequence IS NOT NULL
|
|
||||||
ORDER BY server_sequence`, req.SinceSequence)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type opDTO struct {
|
|
||||||
OpID string `json:"op_id"`
|
|
||||||
ServerSequence int `json:"server_sequence"`
|
|
||||||
DeviceID string `json:"device_id"`
|
|
||||||
EntityType string `json:"entity_type"`
|
|
||||||
EntityID string `json:"entity_id"`
|
|
||||||
OpType string `json:"op_type"`
|
|
||||||
PayloadJSON string `json:"payload_json"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
var ops []opDTO
|
|
||||||
for rows.Next() {
|
|
||||||
var o opDTO
|
|
||||||
if err := rows.Scan(&o.OpID, &o.ServerSequence, &o.DeviceID, &o.EntityType, &o.EntityID, &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ops = append(ops, o)
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonOK(w, map[string]interface{}{
|
|
||||||
"server_sequence": serverSeq,
|
|
||||||
"ops": ops,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireAPIKey(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch r.Method {
|
|
||||||
case "POST":
|
|
||||||
// Upload: accept multipart file, store by SHA-256.
|
|
||||||
if err := r.ParseMultipartForm(200 << 20); err != nil {
|
|
||||||
jsonErr(w, 400, "multipart error: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file, header, err := r.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 400, "file field required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// Read content and compute SHA-256.
|
|
||||||
data, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, "read error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash := sha256.Sum256(data)
|
|
||||||
shaHex := hex.EncodeToString(hash[:])
|
|
||||||
|
|
||||||
// Store at blobs/ab/cd/sha256.
|
|
||||||
blobDir := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4])
|
|
||||||
if err := os.MkdirAll(blobDir, 0750); err != nil {
|
|
||||||
jsonErr(w, 500, "mkdir error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
blobPath := filepath.Join(blobDir, shaHex)
|
|
||||||
if err := os.WriteFile(blobPath, data, 0640); err != nil {
|
|
||||||
jsonErr(w, 500, "write error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = header
|
|
||||||
|
|
||||||
// Record in blobs table.
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
s.db.Exec("INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)",
|
|
||||||
shaHex, len(data), now)
|
|
||||||
|
|
||||||
jsonOK(w, map[string]interface{}{
|
|
||||||
"sha256": shaHex,
|
|
||||||
"size": len(data),
|
|
||||||
})
|
|
||||||
|
|
||||||
case "GET":
|
|
||||||
// Download: GET /api/v1/blobs/{sha256}
|
|
||||||
shaHex := strings.TrimPrefix(r.URL.Path, "/api/v1/blobs/")
|
|
||||||
if len(shaHex) != 64 {
|
|
||||||
jsonErr(w, 400, "invalid SHA-256")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
blobPath := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4], shaHex)
|
|
||||||
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
|
|
||||||
jsonErr(w, 404, "blob not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(blobPath)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, "read error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+shaHex+"\"")
|
|
||||||
w.Write(data)
|
|
||||||
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"verstak/internal/i18n"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
||||||
cookie, err := r.Cookie("user_session")
|
|
||||||
if err != nil {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
userID, ok := s.userTokens.Check(cookie.Value)
|
|
||||||
if !ok {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return userID, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(userRegisterHTML("ru")))
|
|
||||||
case "POST":
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(400)
|
|
||||||
w.Write([]byte("<html><body><h1>400 Bad request</h1><a href='/register'>Back</a></body></html>"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
username := r.FormValue("username")
|
|
||||||
email := r.FormValue("email")
|
|
||||||
password := r.FormValue("password")
|
|
||||||
if username == "" || email == "" || password == "" {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(400)
|
|
||||||
w.Write([]byte("<html><body><h1>All fields required</h1><a href='/register'>Back</a></body></html>"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validatePassword(password); err != "" {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(400)
|
|
||||||
w.Write([]byte("<html><body><h1>" + err + "</h1><a href='/register'>Back</a></body></html>"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(500)
|
|
||||||
w.Write([]byte("<html><body><h1>Internal error</h1><a href='/register'>Back</a></body></html>"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
id := make([]byte, 12)
|
|
||||||
rand.Read(id)
|
|
||||||
userID := hex.EncodeToString(id)
|
|
||||||
_, err = s.db.Exec(
|
|
||||||
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
|
|
||||||
userID, username, strings.ToLower(email), string(hash), now,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
|
||||||
w.WriteHeader(409)
|
|
||||||
w.Write([]byte("<html><body><h1>Username or email already taken</h1><a href='/register'>Back</a></body></html>"))
|
|
||||||
} else {
|
|
||||||
w.WriteHeader(500)
|
|
||||||
w.Write([]byte("<html><body><h1>" + err.Error() + "</h1><a href='/register'>Back</a></body></html>"))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Confirmation token.
|
|
||||||
tok := make([]byte, 24)
|
|
||||||
rand.Read(tok)
|
|
||||||
tokenStr := hex.EncodeToString(tok)
|
|
||||||
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
|
||||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
|
|
||||||
tokenStr, userID, exp, now)
|
|
||||||
// Try to send email.
|
|
||||||
host := s.smtpGet("smtp_host")
|
|
||||||
if host != "" {
|
|
||||||
srvURL := s.smtpGet("server_url")
|
|
||||||
var confirmURL string
|
|
||||||
if srvURL != "" {
|
|
||||||
confirmURL = fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", srvURL, tokenStr)
|
|
||||||
} else {
|
|
||||||
confirmURL = fmt.Sprintf("http://%s/api/v1/auth/confirm?token=%s", r.Host, tokenStr)
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf("Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.", confirmURL)
|
|
||||||
if err := s.smtpSend(email, "Confirm your Verstak Sync account", body); err != nil {
|
|
||||||
log.Printf("register web: failed to send confirm email: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Printf("register web: SMTP not configured, confirmation token=%s for user %s", tokenStr, username)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
regMsg := registrationOKHTML("ru")
|
|
||||||
if host == "" {
|
|
||||||
regMsg = registrationAutoHTML("ru")
|
|
||||||
}
|
|
||||||
w.Write([]byte(regMsg))
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(forgotPasswordHTML("ru")))
|
|
||||||
case "POST":
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
jsonErr(w, 400, "bad form")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
email := strings.ToLower(r.FormValue("email"))
|
|
||||||
if email == "" {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(errorPageHTML("ru", i18n.T("ru", "common.error"), i18n.T("ru", "server.needEmail"), "/forgot")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var userID string
|
|
||||||
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID)
|
|
||||||
if err != nil {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(forgotSentHTML("ru")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tok := make([]byte, 24)
|
|
||||||
rand.Read(tok)
|
|
||||||
tokenStr := hex.EncodeToString(tok)
|
|
||||||
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
|
|
||||||
tokenStr, userID, exp, now)
|
|
||||||
host := s.smtpGet("smtp_host")
|
|
||||||
if host != "" {
|
|
||||||
srvURL := s.smtpGet("server_url")
|
|
||||||
resetURL := fmt.Sprintf("/reset?token=%s", tokenStr)
|
|
||||||
if srvURL != "" {
|
|
||||||
resetURL = fmt.Sprintf("%s/reset?token=%s", srvURL, tokenStr)
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf("Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.", resetURL)
|
|
||||||
if err := s.smtpSend(email, "Verstak Sync password reset", body); err != nil {
|
|
||||||
log.Printf("forgot web: failed to send reset email: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Printf("forgot web: SMTP not configured, reset token=%s for email %s", tokenStr, email)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(forgotSentHTML("ru")))
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
token := r.URL.Query().Get("token")
|
|
||||||
if token == "" {
|
|
||||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Validate token exists and not expired before showing form.
|
|
||||||
var userID, expiresAt string
|
|
||||||
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
|
|
||||||
token).Scan(&userID, &expiresAt)
|
|
||||||
if err != nil {
|
|
||||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
|
||||||
if err != nil || time.Now().After(exp) {
|
|
||||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
html := strings.ReplaceAll(resetPasswordHTML("ru"), "{TOKEN}", token)
|
|
||||||
w.Write([]byte(html))
|
|
||||||
case "POST":
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
jsonErr(w, 400, "bad form")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token := r.FormValue("token")
|
|
||||||
newPass := r.FormValue("password")
|
|
||||||
confirm := r.FormValue("confirm")
|
|
||||||
if token == "" || newPass == "" || confirm == "" {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(errorPageHTML("ru", i18n.T("ru", "common.error"), i18n.T("ru", "server.allFieldsRequired"), "/forgot")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validatePassword(newPass); err != "" {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(errorPageHTML("ru", i18n.T("ru", "common.error"), err, "/reset?token="+token)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if newPass != confirm {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(errorPageHTML("ru", i18n.T("ru", "common.error"), i18n.T("ru", "server.passwordsDoNotMatch"), "/reset?token="+token)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var userID string
|
|
||||||
err := s.db.QueryRow("SELECT user_id FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID)
|
|
||||||
if err != nil {
|
|
||||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
|
|
||||||
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
|
|
||||||
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", token)
|
|
||||||
log.Printf("reset: user %s reset password", userID)
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(resetDoneHTML("ru")))
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "GET":
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(userLoginHTML("ru")))
|
|
||||||
case "POST":
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
jsonErr(w, 400, "bad form")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
username := r.FormValue("username")
|
|
||||||
password := r.FormValue("password")
|
|
||||||
var userID, hash string
|
|
||||||
var confirmed, blocked int
|
|
||||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
|
||||||
username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed, &blocked)
|
|
||||||
if err != nil || blocked != 0 || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(401)
|
|
||||||
w.Write([]byte("<html><body><h1>401 Unauthorized</h1><a href='/login'>Try again</a></body></html>"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tok := s.userTokens.Create(userID)
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "user_session", Value: tok, Path: "/",
|
|
||||||
HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
||||||
MaxAge: 86400,
|
|
||||||
})
|
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
|
||||||
default:
|
|
||||||
jsonErr(w, 405, "method not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID, ok := s.requireUserWeb(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var username string
|
|
||||||
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
|
|
||||||
|
|
||||||
// Get devices with status info.
|
|
||||||
type dev struct {
|
|
||||||
ID, Name, LastSeen, CreatedAt, ClientVer, RevokedAt string
|
|
||||||
}
|
|
||||||
var devices []dev
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT d.id, d.name, COALESCE(d.last_seen,''), d.created_at,
|
|
||||||
COALESCE(d.client_version,''), COALESCE(d.revoked_at,'')
|
|
||||||
FROM server_devices d
|
|
||||||
JOIN server_user_devices ud ON ud.device_id = d.id
|
|
||||||
WHERE ud.user_id = ?
|
|
||||||
ORDER BY d.created_at DESC`, userID)
|
|
||||||
if err == nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var d dev
|
|
||||||
rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt)
|
|
||||||
devices = append(devices, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
deviceRows := ""
|
|
||||||
if len(devices) == 0 {
|
|
||||||
deviceRows = "<tr><td colspan='5' style='color:#666;text-align:center;padding:24px'>Нет подключённых устройств.<br>Подключите устройство из desktop-клиента Verstak.</td></tr>"
|
|
||||||
} else {
|
|
||||||
for _, d := range devices {
|
|
||||||
ls := d.LastSeen
|
|
||||||
if ls == "" {
|
|
||||||
ls = "—"
|
|
||||||
}
|
|
||||||
created := d.CreatedAt
|
|
||||||
if len(created) > 10 {
|
|
||||||
created = created[:10]
|
|
||||||
}
|
|
||||||
status := "<span style='color:#34d399'>Активно</span>"
|
|
||||||
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice('%s')">Отозвать</button>`, d.ID)
|
|
||||||
if d.RevokedAt != "" {
|
|
||||||
status = "<span style='color:#ff6b6b'>Отозвано</span>"
|
|
||||||
revokeBtn = ""
|
|
||||||
}
|
|
||||||
deviceRows += fmt.Sprintf(`<tr>
|
|
||||||
<td>%s</td>
|
|
||||||
<td>%s</td>
|
|
||||||
<td>%s</td>
|
|
||||||
<td>%s</td>
|
|
||||||
<td>%s %s</td>
|
|
||||||
</tr>`, d.Name, status, created, ls, d.ClientVer, revokeBtn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Verstak Sync — %s</title>
|
|
||||||
<style>
|
|
||||||
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:800px;margin:0 auto}
|
|
||||||
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
|
|
||||||
h2{margin-top:24px;font-size:16px}
|
|
||||||
table{width:100%%;border-collapse:collapse;margin-top:8px}
|
|
||||||
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
|
|
||||||
th{font-size:12px;color:#888;text-transform:uppercase}
|
|
||||||
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
|
|
||||||
.btn:hover{background:#222233}
|
|
||||||
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
|
|
||||||
.btn-primary:hover{background:#4f46e5}
|
|
||||||
.btn-danger{color:#ff6b6b;border-color:#4a2222}
|
|
||||||
.btn-danger:hover{background:#3a2222}
|
|
||||||
.btn-sm{padding:2px 8px;font-size:11px}
|
|
||||||
.top{display:flex;justify-content:space-between;align-items:center}
|
|
||||||
a{color:#6366f1}
|
|
||||||
</style>
|
|
||||||
</head><body>
|
|
||||||
<div class="top">
|
|
||||||
<h1>Verstak Sync</h1>
|
|
||||||
<span>%s · <a href="/logout">Выйти</a></span>
|
|
||||||
</div>
|
|
||||||
<h2>Устройства</h2>
|
|
||||||
<table><tr><th>Устройство</th><th>Статус</th><th>Подключено</th><th>Активность</th><th>Версия</th></tr>%s</table>
|
|
||||||
|
|
||||||
<div style="margin-top:24px;padding:16px;background:#1a1a28;border:1px solid #2a2a3c;border-radius:8px">
|
|
||||||
<h2 style="margin-top:0">Подключить новое устройство</h2>
|
|
||||||
<p style="font-size:13px;color:#888">Откройте desktop-клиент Verstak, перейдите в настройки синхронизации и введите URL сервера, логин и пароль.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function revokeDevice(id){
|
|
||||||
if(!confirm('Отозвать устройство? Оно перестанет синхронизироваться.'))return
|
|
||||||
var pw=prompt('Введите ваш пароль для подтверждения:')
|
|
||||||
if(!pw)return
|
|
||||||
fetch('/api/client/revoke-device',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({device_id:id,password:pw})}).then(function(r){return r.json()}).then(function(d){
|
|
||||||
if(d.status==='revoked'){location.reload()}else{alert(d.error||'error')}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body></html>`, username, username, deviceRows)
|
|
||||||
w.Write([]byte(html))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "user_session", Value: "", Path: "/",
|
|
||||||
HttpOnly: true, MaxAge: -1,
|
|
||||||
})
|
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func jsonOK(w http.ResponseWriter, v interface{}) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonErr(w http.ResponseWriter, code int, msg string) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(code)
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) requireAPIKey(w http.ResponseWriter, r *http.Request) bool {
|
|
||||||
key := r.Header.Get("Authorization")
|
|
||||||
key = strings.TrimPrefix(key, "Bearer ")
|
|
||||||
if key == "" {
|
|
||||||
key = r.URL.Query().Get("api_key")
|
|
||||||
}
|
|
||||||
if key == "" {
|
|
||||||
jsonErr(w, 401, "API key required")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// First try device token (hashed).
|
|
||||||
hash := sha256Hex(key)
|
|
||||||
var deviceID, userID, revokedAt sql.NullString
|
|
||||||
err := s.db.QueryRow("SELECT id, user_id, revoked_at FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID, &revokedAt)
|
|
||||||
if err == nil {
|
|
||||||
if revokedAt.Valid && revokedAt.String != "" {
|
|
||||||
jsonErr(w, 401, "device revoked")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Check user not blocked.
|
|
||||||
var blocked int
|
|
||||||
if userID.Valid && userID.String != "" {
|
|
||||||
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", userID.String).Scan(&blocked)
|
|
||||||
if blocked != 0 {
|
|
||||||
jsonErr(w, 403, "user blocked")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.Header.Set("X-Device-ID", deviceID.String)
|
|
||||||
r.Header.Set("X-User-ID", userID.String)
|
|
||||||
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), deviceID.String)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Fallback to plain api_key (legacy).
|
|
||||||
var count int
|
|
||||||
err = s.db.QueryRow("SELECT COUNT(*) FROM server_devices WHERE api_key=?", key).Scan(&count)
|
|
||||||
if err != nil || count == 0 {
|
|
||||||
jsonErr(w, 401, "invalid API key")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
|
||||||
cookie, err := r.Cookie("session")
|
|
||||||
if err != nil || !s.tokens.Check(cookie.Value) {
|
|
||||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func validatePassword(password string) string {
|
|
||||||
if len(password) < 8 {
|
|
||||||
return "Password must be at least 8 characters"
|
|
||||||
}
|
|
||||||
if !passwordRE.MatchString(password) {
|
|
||||||
return "Password must contain only Latin letters and digits"
|
|
||||||
}
|
|
||||||
hasLetter := false
|
|
||||||
hasDigit := false
|
|
||||||
for _, ch := range password {
|
|
||||||
if ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' {
|
|
||||||
hasLetter = true
|
|
||||||
}
|
|
||||||
if ch >= '0' && ch <= '9' {
|
|
||||||
hasDigit = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasLetter || !hasDigit {
|
|
||||||
return "Password must contain both letters and digits"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
||||||
key := r.Header.Get("Authorization")
|
|
||||||
key = strings.TrimPrefix(key, "Bearer ")
|
|
||||||
if key == "" {
|
|
||||||
jsonErr(w, 401, "authorization required")
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
userID, ok := s.userTokens.Check(key)
|
|
||||||
if !ok {
|
|
||||||
jsonErr(w, 401, "invalid or expired token")
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return userID, true
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import "net/http"
|
|
||||||
|
|
||||||
func (s *Server) routes() *http.ServeMux {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/api/v1/health", s.handleHealth)
|
|
||||||
mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister)
|
|
||||||
mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush)
|
|
||||||
mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull)
|
|
||||||
mux.HandleFunc("/api/v1/blobs/", s.handleBlobs)
|
|
||||||
mux.HandleFunc("/api/client/pair", s.handleClientPair)
|
|
||||||
mux.HandleFunc("/api/auth/test", s.handleAuthTest)
|
|
||||||
mux.HandleFunc("/api/client/revoke-current", s.handleClientRevoke)
|
|
||||||
mux.HandleFunc("/api/client/me", s.handleClientMe)
|
|
||||||
mux.HandleFunc("/api/client/revoke-device", s.handleClientRevokeDevice)
|
|
||||||
mux.HandleFunc("/api/v1/auth/register", s.handleRegister)
|
|
||||||
mux.HandleFunc("/api/v1/auth/confirm", s.handleConfirm)
|
|
||||||
mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
|
|
||||||
mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot)
|
|
||||||
mux.HandleFunc("/api/v1/auth/reset", s.handleReset)
|
|
||||||
mux.HandleFunc("/forgot", s.handleUserWebForgot)
|
|
||||||
mux.HandleFunc("/reset", s.handleUserWebReset)
|
|
||||||
mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices)
|
|
||||||
mux.HandleFunc("/register", s.handleUserWebRegister)
|
|
||||||
mux.HandleFunc("/login", s.handleUserWebLogin)
|
|
||||||
mux.HandleFunc("/dashboard", s.handleUserDashboard)
|
|
||||||
mux.HandleFunc("/logout", s.handleUserWebLogout)
|
|
||||||
mux.HandleFunc("/admin/login", s.handleAdminLogin)
|
|
||||||
mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
|
|
||||||
mux.HandleFunc("/admin/users", s.handleAdminUsers)
|
|
||||||
mux.HandleFunc("/admin/api/stats", s.handleAdminStats)
|
|
||||||
mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest)
|
|
||||||
mux.HandleFunc("/admin/", s.handleAdminAPI)
|
|
||||||
mux.HandleFunc("/", s.handleNotFound)
|
|
||||||
return mux
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
const serverSchema = `
|
|
||||||
CREATE TABLE IF NOT EXISTS server_devices (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
api_key TEXT NOT NULL UNIQUE,
|
|
||||||
token_hash TEXT,
|
|
||||||
token_prefix TEXT,
|
|
||||||
token_suffix TEXT,
|
|
||||||
user_id TEXT,
|
|
||||||
client_version TEXT,
|
|
||||||
last_ip TEXT,
|
|
||||||
last_seen TEXT,
|
|
||||||
revoked_at TEXT,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_revisions (
|
|
||||||
rev INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
op_id TEXT NOT NULL,
|
|
||||||
device_id TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_ops (
|
|
||||||
op_id TEXT PRIMARY KEY,
|
|
||||||
server_sequence INTEGER,
|
|
||||||
device_id TEXT NOT NULL,
|
|
||||||
entity_type TEXT NOT NULL,
|
|
||||||
entity_id TEXT NOT NULL,
|
|
||||||
op_type TEXT NOT NULL,
|
|
||||||
payload_json TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT,
|
|
||||||
client_sequence INTEGER DEFAULT 0,
|
|
||||||
last_seen_server_seq INTEGER DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
pushed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_tombstones (
|
|
||||||
entity_type TEXT NOT NULL,
|
|
||||||
entity_id TEXT NOT NULL,
|
|
||||||
op_id TEXT NOT NULL,
|
|
||||||
deleted_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (entity_type, entity_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_idempotency_keys (
|
|
||||||
idempotency_key TEXT PRIMARY KEY,
|
|
||||||
response_json TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_blobs (
|
|
||||||
sha256 TEXT PRIMARY KEY,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_smtp_config (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
confirmed INTEGER NOT NULL DEFAULT 0,
|
|
||||||
blocked INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_seen TEXT,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_email_tokens (
|
|
||||||
token TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
purpose TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_user_devices (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
device_id TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (user_id, device_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_audit_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
user_id TEXT,
|
|
||||||
device_id TEXT,
|
|
||||||
ip TEXT,
|
|
||||||
message TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
`
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,156 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/smtp"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) smtpGet(key string) string {
|
|
||||||
var val string
|
|
||||||
s.db.QueryRow("SELECT value FROM server_smtp_config WHERE key=?", key).Scan(&val)
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) smtpSet(key, val string) error {
|
|
||||||
_, err := s.db.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, val)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func sha256Hex(s string) string {
|
|
||||||
h := sha256.Sum256([]byte(s))
|
|
||||||
return hex.EncodeToString(h[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func genDeviceToken() (token, prefix, suffix string) {
|
|
||||||
b := make([]byte, 32)
|
|
||||||
rand.Read(b)
|
|
||||||
token = "vs_dev_" + hex.EncodeToString(b)
|
|
||||||
prefix = token[:16]
|
|
||||||
suffix = token[len(token)-8:]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func sel(v, want string) string {
|
|
||||||
if v == want {
|
|
||||||
return " selected"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) smtpConnect(host, port, user, pass, security string) (*smtp.Client, error) {
|
|
||||||
addr := net.JoinHostPort(host, port)
|
|
||||||
switch security {
|
|
||||||
case "tls":
|
|
||||||
tlsCfg := &tls.Config{ServerName: host}
|
|
||||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("tls dial: %w", err)
|
|
||||||
}
|
|
||||||
cl, err := smtp.NewClient(conn, host)
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, fmt.Errorf("smtp client: %w", err)
|
|
||||||
}
|
|
||||||
return cl, nil
|
|
||||||
default:
|
|
||||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("connect: %w", err)
|
|
||||||
}
|
|
||||||
cl, err := smtp.NewClient(conn, host)
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, fmt.Errorf("smtp client: %w", err)
|
|
||||||
}
|
|
||||||
if security != "none" {
|
|
||||||
if ok, _ := cl.Extension("STARTTLS"); ok {
|
|
||||||
tlsCfg := &tls.Config{ServerName: host}
|
|
||||||
if err := cl.StartTLS(tlsCfg); err != nil {
|
|
||||||
cl.Close()
|
|
||||||
return nil, fmt.Errorf("starttls: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cl, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) smtpSendMsg(cl *smtp.Client, user, pass, host, from, to string, msg []byte) error {
|
|
||||||
if user != "" {
|
|
||||||
auth := smtp.PlainAuth("", user, pass, host)
|
|
||||||
if err := cl.Auth(auth); err != nil {
|
|
||||||
return fmt.Errorf("auth: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := cl.Mail(from); err != nil {
|
|
||||||
return fmt.Errorf("mail from: %w", err)
|
|
||||||
}
|
|
||||||
if err := cl.Rcpt(to); err != nil {
|
|
||||||
return fmt.Errorf("rcpt: %w", err)
|
|
||||||
}
|
|
||||||
w, err := cl.Data()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("data: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := w.Write(msg); err != nil {
|
|
||||||
w.Close()
|
|
||||||
return fmt.Errorf("write: %w", err)
|
|
||||||
}
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
return fmt.Errorf("send: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) smtpSend(to, subject, body string) error {
|
|
||||||
host := s.smtpGet("smtp_host")
|
|
||||||
port := s.smtpGet("smtp_port")
|
|
||||||
user := s.smtpGet("smtp_user")
|
|
||||||
pass := s.smtpGet("smtp_pass")
|
|
||||||
from := s.smtpGet("smtp_from")
|
|
||||||
security := s.smtpGet("smtp_security")
|
|
||||||
if host == "" || port == "" || from == "" {
|
|
||||||
err := fmt.Errorf("SMTP not configured")
|
|
||||||
log.Printf("smtp: %v (to=%s)", err, to)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Printf("smtp: sending to %s via %s:%s (security=%s)", to, host, port, security)
|
|
||||||
msg := []byte("From: " + from + "\r\n" +
|
|
||||||
"To: " + to + "\r\n" +
|
|
||||||
"Subject: " + subject + "\r\n" +
|
|
||||||
"MIME-Version: 1.0\r\n" +
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
|
||||||
"\r\n" + body + "\r\n")
|
|
||||||
cl, err := s.smtpConnect(host, port, user, pass, security)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("smtp: connect error: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cl.Close()
|
|
||||||
if err := s.smtpSendMsg(cl, user, pass, host, from, to, msg); err != nil {
|
|
||||||
log.Printf("smtp: send error: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Printf("smtp: sent OK to %s", to)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) smtpTest(host, port, user, pass, security, from, to string) error {
|
|
||||||
if host == "" || port == "" || from == "" {
|
|
||||||
return fmt.Errorf("SMTP not configured")
|
|
||||||
}
|
|
||||||
msg := []byte("From: " + from + "\r\nTo: " + to + "\r\nSubject: Test from Verstak Sync\r\n\r\nThis is a test email from Verstak Sync Server.\r\n")
|
|
||||||
cl, err := s.smtpConnect(host, port, user, pass, security)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cl.Close()
|
|
||||||
return s.smtpSendMsg(cl, user, pass, host, from, to, msg)
|
|
||||||
}
|
|
||||||
|
|
@ -1,576 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"verstak/internal/i18n"
|
|
||||||
)
|
|
||||||
|
|
||||||
func userRegisterHTML(locale string) string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Verstak Sync — %s</title>
|
|
||||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
||||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
|
||||||
h1{font-size:20px;margin:0 0 20px;text-align:center}
|
|
||||||
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
|
||||||
a{color:#6366f1}
|
|
||||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
|
||||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
|
||||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
|
||||||
button:hover{background:#4f46e5}
|
|
||||||
.hint{font-size:11px;color:#666;margin-top:-12px;margin-bottom:16px;text-align:center}
|
|
||||||
</style>
|
|
||||||
</head><body>
|
|
||||||
<form method="POST">
|
|
||||||
<h1>%s</h1>
|
|
||||||
<label>%s</label>
|
|
||||||
<input type="text" name="username" autofocus required>
|
|
||||||
<label>%s</label>
|
|
||||||
<input type="email" name="email" required>
|
|
||||||
<label>%s</label>
|
|
||||||
<input type="password" name="password" required minlength="8">
|
|
||||||
<div class="hint">%s</div>
|
|
||||||
<button>%s</button>
|
|
||||||
<p>%s <a href="/login">%s</a></p>
|
|
||||||
</form>
|
|
||||||
</body></html>`,
|
|
||||||
i18n.T(locale, "server.registerTitle"),
|
|
||||||
i18n.T(locale, "server.register"),
|
|
||||||
i18n.T(locale, "server.username"),
|
|
||||||
i18n.T(locale, "server.email"),
|
|
||||||
i18n.T(locale, "server.password"),
|
|
||||||
i18n.T(locale, "server.passwordHint"),
|
|
||||||
i18n.T(locale, "server.registerBtn"),
|
|
||||||
i18n.T(locale, "server.alreadyHaveAccount"),
|
|
||||||
i18n.T(locale, "server.loginBtn"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func userLoginHTML(locale string) string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Verstak Sync — %s</title>
|
|
||||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
||||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
|
||||||
h1{font-size:20px;margin:0 0 20px;text-align:center}
|
|
||||||
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
|
||||||
a{color:#6366f1}
|
|
||||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
|
||||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
|
||||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
|
||||||
button:hover{background:#4f46e5}
|
|
||||||
.links{margin-top:16px;text-align:center;font-size:12px;color:#666;line-height:1.8}
|
|
||||||
.links a{color:#6366f1;text-decoration:none}
|
|
||||||
.links a:hover{text-decoration:underline}</style>
|
|
||||||
</head><body>
|
|
||||||
<form method="POST">
|
|
||||||
<h1>Verstak Sync</h1>
|
|
||||||
<label>%s</label>
|
|
||||||
<input type="text" name="username" autofocus required>
|
|
||||||
<label>%s</label>
|
|
||||||
<input type="password" name="password" required>
|
|
||||||
<button>%s</button>
|
|
||||||
<div class="links">
|
|
||||||
<a href="/forgot">%s</a><br>
|
|
||||||
<a href="/register">%s</a> · <a href="/admin/login">%s</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</body></html>`,
|
|
||||||
i18n.T(locale, "server.loginTitle"),
|
|
||||||
i18n.T(locale, "server.usernameOrEmail"),
|
|
||||||
i18n.T(locale, "server.password"),
|
|
||||||
i18n.T(locale, "server.loginBtn"),
|
|
||||||
i18n.T(locale, "server.forgotPassword"),
|
|
||||||
i18n.T(locale, "server.registerBtn"),
|
|
||||||
i18n.T(locale, "server.adminLink"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func adminLoginHTML(locale string) string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
||||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
|
||||||
h1{font-size:20px;margin:0 0 20px;text-align:center}
|
|
||||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
|
||||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
|
||||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
|
||||||
button:hover{background:#4f46e5}</style>
|
|
||||||
</head><body>
|
|
||||||
<form method="POST">
|
|
||||||
<h1>Verstak Sync</h1>
|
|
||||||
<label>%s</label>
|
|
||||||
<input type="text" name="username" autofocus required>
|
|
||||||
<label>%s</label>
|
|
||||||
<input type="password" name="password" required>
|
|
||||||
<button>%s</button>
|
|
||||||
</form>
|
|
||||||
</body></html>`,
|
|
||||||
i18n.T(locale, "admin.login"),
|
|
||||||
i18n.T(locale, "admin.username"),
|
|
||||||
i18n.T(locale, "admin.password"),
|
|
||||||
i18n.T(locale, "admin.loginBtn"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func adminUsersHTML(locale string) string {
|
|
||||||
newPassResult := i18n.T(locale, "server.newPasswordResult")
|
|
||||||
newPassParts := strings.SplitN(newPassResult, "%s", 2)
|
|
||||||
newPassPrefix := newPassParts[0]
|
|
||||||
newPassSuffix := strings.ReplaceAll(newPassParts[1], "\n", "\\n")
|
|
||||||
|
|
||||||
deleteMsg := i18n.T(locale, "admin.deleteUserMessage")
|
|
||||||
deleteMsgParts := strings.SplitN(deleteMsg, "%s", 2)
|
|
||||||
delMsgPrefix := deleteMsgParts[0]
|
|
||||||
delMsgSuffix := deleteMsgParts[1]
|
|
||||||
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>%[1]s</title>
|
|
||||||
<style>
|
|
||||||
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:960px;margin:0 auto}
|
|
||||||
a{color:#6366f1}
|
|
||||||
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
|
|
||||||
table{width:100%%;border-collapse:collapse;margin-top:12px}
|
|
||||||
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
|
|
||||||
th{font-size:12px;color:#888;text-transform:uppercase;cursor:pointer;user-select:none}
|
|
||||||
th:hover{color:#b0b0c0}
|
|
||||||
th.sorted{color:#6366f1}
|
|
||||||
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
|
|
||||||
.btn:hover{background:#222233}
|
|
||||||
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
|
|
||||||
.btn-primary:hover{background:#4f46e5}
|
|
||||||
.btn-danger{color:#ff6b6b;border-color:#4a2222}
|
|
||||||
.btn-danger:hover{background:#3a2222}
|
|
||||||
.btn-sm{padding:2px 8px;font-size:11px}
|
|
||||||
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;box-sizing:border-box}
|
|
||||||
input:focus{outline:none;border-color:#6366f1}
|
|
||||||
.toolbar{display:flex;gap:8px;margin:12px 0;flex-wrap:wrap;align-items:center}
|
|
||||||
.pagination{display:flex;gap:8px;margin-top:12px;align-items:center;justify-content:center}
|
|
||||||
.pagination span{padding:4px 8px;font-size:12px;color:#888}
|
|
||||||
.badge{padding:2px 8px;border-radius:4px;font-size:11px}
|
|
||||||
.badge-green{background:#064e3b;color:#34d399}
|
|
||||||
.badge-red{background:#4a2222;color:#ff6b6b}
|
|
||||||
.badge-yellow{background:#4a3e00;color:#fbbf24}
|
|
||||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
|
|
||||||
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:400px;max-width:90vw;position:relative}
|
|
||||||
.modal h2{margin-top:0;font-size:16px}
|
|
||||||
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
|
|
||||||
.modal-close:hover{color:#e4e4ef}
|
|
||||||
.form-row{display:flex;gap:8px;margin-bottom:12px;align-items:center}
|
|
||||||
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
|
|
||||||
.form-row input{flex:1}
|
|
||||||
</style>
|
|
||||||
</head><body>
|
|
||||||
<h1>%[2]s</h1>
|
|
||||||
<p><a href="/admin/dashboard">%[3]s</a></p>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
|
||||||
<input id="filter-input" placeholder="%[4]s" style="width:200px" onkeyup="loadUsers()">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead><tr>
|
|
||||||
<th onclick="sortBy('username')">%[5]s <span id="s-username"></span></th>
|
|
||||||
<th onclick="sortBy('email')">%[6]s <span id="s-email"></span></th>
|
|
||||||
<th onclick="sortBy('confirmed')">%[7]s <span id="s-confirmed"></span></th>
|
|
||||||
<th onclick="sortBy('devices')">%[8]s <span id="s-devices"></span></th>
|
|
||||||
<th onclick="sortBy('last_seen')">%[9]s <span id="s-last_seen"></span></th>
|
|
||||||
<th>%[10]s</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody id="users-tbody"></tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="pagination" id="pagination"></div>
|
|
||||||
|
|
||||||
<div id="confirm-modal" class="modal-overlay" style="display:none">
|
|
||||||
<div class="modal">
|
|
||||||
<button class="modal-close" onclick="closeConfirm()">×</button>
|
|
||||||
<h2 id="confirm-title">%[11]s</h2>
|
|
||||||
<p id="confirm-text"></p>
|
|
||||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
|
||||||
<button class="btn" onclick="closeConfirm()">%[12]s</button>
|
|
||||||
<button class="btn btn-danger" id="confirm-btn" onclick="confirmAction()">%[13]s</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="edit-modal" class="modal-overlay" style="display:none">
|
|
||||||
<div class="modal">
|
|
||||||
<button class="modal-close" onclick="closeEdit()">×</button>
|
|
||||||
<h2>%[14]s</h2>
|
|
||||||
<div class="form-row"><label>%[15]s</label><input id="edit-username"></div>
|
|
||||||
<div class="form-row"><label>%[16]s</label><input id="edit-email" type="email"></div>
|
|
||||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
|
||||||
<button class="btn" onclick="closeEdit()">%[17]s</button>
|
|
||||||
<button class="btn btn-primary" onclick="saveEdit()">%[18]s</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="result-modal" class="modal-overlay" style="display:none">
|
|
||||||
<div class="modal" style="width:320px">
|
|
||||||
<button class="modal-close" onclick="closeResult()">×</button>
|
|
||||||
<h2 id="result-title">%[19]s</h2>
|
|
||||||
<p id="result-text" style="white-space:pre-wrap"></p>
|
|
||||||
<button class="btn btn-primary" onclick="closeResult()" style="margin-top:8px">%[20]s</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
var currentPage=1,currentSort='',currentOrder='',editUserId='',pendingAction=''
|
|
||||||
|
|
||||||
function loadUsers(){
|
|
||||||
var f=document.getElementById('filter-input').value
|
|
||||||
var u='/admin/api/users?page='+currentPage+'&per_page=20&filter='+encodeURIComponent(f)
|
|
||||||
if(currentSort){u+='&sort='+currentSort+'&order='+currentOrder}
|
|
||||||
fetch(u).then(function(r){return r.json()}).then(function(d){
|
|
||||||
var tbody=document.getElementById('users-tbody')
|
|
||||||
tbody.innerHTML=''
|
|
||||||
d.users.forEach(function(u){
|
|
||||||
var status=u.confirmed?'<span class="badge badge-green">%[21]s</span>':'<span class="badge badge-yellow">%[22]s</span>'
|
|
||||||
if(u.blocked){status='<span class="badge badge-red">%[23]s</span>'}
|
|
||||||
var lastSeen=u.last_seen?new Date(u.last_seen).toLocaleString():'-'
|
|
||||||
var blockText=u.blocked?'%[24]s':'%[25]s'
|
|
||||||
var tr=document.createElement('tr')
|
|
||||||
tr.innerHTML='<td>'+esc(u.username)+'</td><td>'+esc(u.email)+'</td><td>'+status+'</td><td>'+u.devices+'</td><td>'+lastSeen+'</td>'+
|
|
||||||
'<td><button class="btn btn-sm" onclick="editUser(\''+u.id+'\',\''+escJS(u.username)+'\',\''+escJS(u.email)+'\')">✎</button> '+
|
|
||||||
'<button class="btn btn-sm" onclick="askBlock(\''+u.id+'\','+u.blocked+')">'+blockText+'</button> '+
|
|
||||||
'<button class="btn btn-sm" onclick="askReset(\''+u.id+'\')">%[26]s</button> '+
|
|
||||||
'<button class="btn btn-sm btn-danger" onclick="askDelete(\''+u.id+'\',\''+escJS(u.username)+'\')">✕</button></td>'
|
|
||||||
tbody.appendChild(tr)
|
|
||||||
})
|
|
||||||
if(!d.users.length){tbody.innerHTML='<tr><td colspan="6" style="text-align:center;color:#666">%[27]s</td></tr>'}
|
|
||||||
var totalPages=Math.ceil(d.total/d.per_page)
|
|
||||||
var pag=document.getElementById('pagination')
|
|
||||||
pag.innerHTML=''
|
|
||||||
if(totalPages>1){
|
|
||||||
var prev=document.createElement('button')
|
|
||||||
prev.className='btn btn-sm';prev.textContent='←';prev.onclick=function(){if(currentPage>1){currentPage--;loadUsers()}}
|
|
||||||
pag.appendChild(prev)
|
|
||||||
var s=document.createElement('span')
|
|
||||||
s.textContent=d.page+' / '+totalPages
|
|
||||||
pag.appendChild(s)
|
|
||||||
var next=document.createElement('button')
|
|
||||||
next.className='btn btn-sm';next.textContent='→';next.onclick=function(){if(currentPage<totalPages){currentPage++;loadUsers()}}
|
|
||||||
pag.appendChild(next)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
function sortBy(col){
|
|
||||||
if(currentSort===col){currentOrder=currentOrder==='asc'?'desc':'asc'}
|
|
||||||
else{currentSort=col;currentOrder='asc'}
|
|
||||||
document.querySelectorAll('th').forEach(function(th){th.classList.remove('sorted')})
|
|
||||||
var el=document.getElementById('s-'+col)
|
|
||||||
if(el){el.parentElement.classList.add('sorted');el.textContent=currentOrder==='asc'?' ▲':' ▼'}
|
|
||||||
loadUsers()
|
|
||||||
}
|
|
||||||
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
|
||||||
function escJS(s){return s.replace(/'/g,"\\'").replace(/"/g,'"')}
|
|
||||||
function editUser(id,username,email){
|
|
||||||
editUserId=id;document.getElementById('edit-username').value=username;document.getElementById('edit-email').value=email;document.getElementById('edit-modal').style.display='flex'}
|
|
||||||
function closeEdit(){document.getElementById('edit-modal').style.display='none'}
|
|
||||||
function saveEdit(){
|
|
||||||
var un=document.getElementById('edit-username').value,em=document.getElementById('edit-email').value
|
|
||||||
if(!un||!em)return
|
|
||||||
fetch('/admin/api/users/'+editUserId+'/edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:un,email:em})}).then(function(r){return r.json()}).then(function(d){closeEdit();if(d.status==='ok')loadUsers()})
|
|
||||||
}
|
|
||||||
function askBlock(id,blocked){
|
|
||||||
pendingAction=function(){fetch('/admin/api/users/'+id+'/block',{method:'POST'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
|
|
||||||
document.getElementById('confirm-title').textContent=blocked?'%[35]s':'%[36]s'
|
|
||||||
document.getElementById('confirm-text').textContent=blocked?'%[37]s':'%[38]s'
|
|
||||||
document.getElementById('confirm-btn').textContent=blocked?'%[24]s':'%[25]s'
|
|
||||||
document.getElementById('confirm-modal').style.display='flex'}
|
|
||||||
function askReset(id){
|
|
||||||
pendingAction=function(){
|
|
||||||
fetch('/admin/api/users/'+id+'/reset-password',{method:'POST'}).then(function(r){return r.json()}).then(function(d){
|
|
||||||
document.getElementById('confirm-modal').style.display='none'
|
|
||||||
document.getElementById('result-title').textContent='%[28]s'
|
|
||||||
document.getElementById('result-text').textContent='%[29]s' + d.new_password + '%[30]s'
|
|
||||||
document.getElementById('result-modal').style.display='flex'})}
|
|
||||||
document.getElementById('confirm-title').textContent='%[31]s'
|
|
||||||
document.getElementById('confirm-text').textContent='%[32]s'
|
|
||||||
document.getElementById('confirm-btn').textContent='%[33]s'
|
|
||||||
document.getElementById('confirm-modal').style.display='flex'}
|
|
||||||
function askDelete(id,username){
|
|
||||||
pendingAction=function(){fetch('/admin/api/users/'+id,{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
|
|
||||||
document.getElementById('confirm-title').textContent='%[34]s'
|
|
||||||
document.getElementById('confirm-text').textContent='%[35]s' + username + '%[36]s'
|
|
||||||
document.getElementById('confirm-btn').textContent='%[37]s'
|
|
||||||
document.getElementById('confirm-modal').style.display='flex'}
|
|
||||||
function closeConfirm(){document.getElementById('confirm-modal').style.display='none';pendingAction=''}
|
|
||||||
function confirmAction(){if(pendingAction){pendingAction();pendingAction=''}}
|
|
||||||
function closeResult(){document.getElementById('result-modal').style.display='none'}
|
|
||||||
loadUsers()
|
|
||||||
</script>
|
|
||||||
</body></html>`,
|
|
||||||
i18n.T(locale, "admin.users"),
|
|
||||||
i18n.T(locale, "admin.usersHeading"),
|
|
||||||
i18n.T(locale, "server.dashboard"),
|
|
||||||
i18n.T(locale, "admin.filterPlaceholder"),
|
|
||||||
i18n.T(locale, "admin.username"),
|
|
||||||
i18n.T(locale, "admin.email"),
|
|
||||||
i18n.T(locale, "admin.status"),
|
|
||||||
i18n.T(locale, "admin.devices"),
|
|
||||||
i18n.T(locale, "admin.lastSeen"),
|
|
||||||
i18n.T(locale, "admin.actions"),
|
|
||||||
i18n.T(locale, "admin.confirmTitle"),
|
|
||||||
i18n.T(locale, "admin.modalCancel"),
|
|
||||||
i18n.T(locale, "admin.modalConfirm"),
|
|
||||||
i18n.T(locale, "admin.editUser"),
|
|
||||||
i18n.T(locale, "admin.username"),
|
|
||||||
i18n.T(locale, "admin.email"),
|
|
||||||
i18n.T(locale, "admin.modalCancel"),
|
|
||||||
i18n.T(locale, "admin.editBtn"),
|
|
||||||
i18n.T(locale, "admin.resultTitle"),
|
|
||||||
i18n.T(locale, "common.ok"),
|
|
||||||
i18n.T(locale, "admin.confirmed"),
|
|
||||||
i18n.T(locale, "admin.unconfirmed"),
|
|
||||||
i18n.T(locale, "admin.blocked"),
|
|
||||||
i18n.T(locale, "admin.unblock"),
|
|
||||||
i18n.T(locale, "admin.block"),
|
|
||||||
i18n.T(locale, "admin.resetPassword"),
|
|
||||||
i18n.T(locale, "admin.noUsers"),
|
|
||||||
i18n.T(locale, "server.newPassword"),
|
|
||||||
newPassPrefix,
|
|
||||||
newPassSuffix,
|
|
||||||
i18n.T(locale, "admin.resetPasswordConfirm"),
|
|
||||||
i18n.T(locale, "admin.resetPasswordMessage"),
|
|
||||||
i18n.T(locale, "admin.resetBtn"),
|
|
||||||
i18n.T(locale, "admin.deleteUser"),
|
|
||||||
delMsgPrefix,
|
|
||||||
delMsgSuffix,
|
|
||||||
i18n.T(locale, "admin.deleteBtn"),
|
|
||||||
i18n.T(locale, "admin.unblockUserTitle"),
|
|
||||||
i18n.T(locale, "admin.blockUserTitle"),
|
|
||||||
i18n.T(locale, "admin.unblockUserMessage"),
|
|
||||||
i18n.T(locale, "admin.blockUserMessage"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmedHTML(locale string) string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Verstak Sync — %s</title>
|
|
||||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
||||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px;text-align:center}
|
|
||||||
h1{font-size:20px;margin:0 0 12px;color:#34d399}
|
|
||||||
p{font-size:13px;color:#b0b0c0;margin:0 0 20px}
|
|
||||||
a{color:#6366f1;text-decoration:none}
|
|
||||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none}
|
|
||||||
.btn:hover{background:#4f46e5}</style>
|
|
||||||
</head><body>
|
|
||||||
<div class="box">
|
|
||||||
<h1>%s</h1>
|
|
||||||
<p>%s</p>
|
|
||||||
<a href="/login" class="btn">%s</a>
|
|
||||||
</div>
|
|
||||||
</body></html>`,
|
|
||||||
i18n.T(locale, "server.emailConfirmed"),
|
|
||||||
i18n.T(locale, "server.emailConfirmed"),
|
|
||||||
i18n.T(locale, "server.emailConfirmedMessage"),
|
|
||||||
i18n.T(locale, "server.loginBtn"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func registrationOKHTML(locale string) string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Verstak Sync — %s</title>
|
|
||||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
||||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
|
||||||
h1{font-size:20px;margin:0 0 12px;color:#34d399}
|
|
||||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
|
||||||
a{color:#6366f1;text-decoration:none}
|
|
||||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
|
||||||
.btn:hover{background:#4f46e5}</style>
|
|
||||||
</head><body>
|
|
||||||
<div class="box">
|
|
||||||
<h1>%s</h1>
|
|
||||||
<p>%s</p>
|
|
||||||
<p>%s</p>
|
|
||||||
<a href="/login" class="btn">%s</a>
|
|
||||||
</div>
|
|
||||||
</body></html>`,
|
|
||||||
i18n.T(locale, "server.registerTitle"),
|
|
||||||
i18n.T(locale, "server.registrationSuccess"),
|
|
||||||
i18n.T(locale, "server.registrationEmailSent"),
|
|
||||||
i18n.T(locale, "server.registrationCheckEmail"),
|
|
||||||
i18n.T(locale, "server.loginBtn"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func registrationAutoHTML(locale string) string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Verstak Sync — %s</title>
|
|
||||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
||||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
|
||||||
h1{font-size:20px;margin:0 0 12px;color:#34d399}
|
|
||||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
|
||||||
a{color:#6366f1;text-decoration:none}
|
|
||||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
|
||||||
.btn:hover{background:#4f46e5}</style>
|
|
||||||
</head><body>
|
|
||||||
<div class="box">
|
|
||||||
<h1>%s</h1>
|
|
||||||
<p>%s</p>
|
|
||||||
<a href="/login" class="btn">%s</a>
|
|
||||||
</div>
|
|
||||||
</body></html>`,
|
|
||||||
i18n.T(locale, "server.registerTitle"),
|
|
||||||
i18n.T(locale, "server.registrationSuccess"),
|
|
||||||
i18n.T(locale, "server.registrationAutoMessage"),
|
|
||||||
i18n.T(locale, "server.loginBtn"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func forgotPasswordHTML(locale string) string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
||||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
|
||||||
h1{font-size:18px;margin:0 0 8px;text-align:center}
|
|
||||||
p{font-size:12px;color:#888;text-align:center;margin:0 0 20px}
|
|
||||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
|
||||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
|
||||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
|
||||||
button:hover{background:#4f46e5}
|
|
||||||
.links{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
|
||||||
.links a{color:#6366f1;text-decoration:none}
|
|
||||||
.links a:hover{text-decoration:underline}</style>
|
|
||||||
</head><body>
|
|
||||||
<form method="POST">
|
|
||||||
<h1>%s</h1>
|
|
||||||
<p>%s</p>
|
|
||||||
<label>%s</label>
|
|
||||||
<input type="email" name="email" autofocus required>
|
|
||||||
<button>%s</button>
|
|
||||||
<div class="links"><a href="/login">%s</a></div>
|
|
||||||
</form>
|
|
||||||
</body></html>`,
|
|
||||||
i18n.T(locale, "server.resetPasswordTitle"),
|
|
||||||
i18n.T(locale, "server.resetPassword"),
|
|
||||||
i18n.T(locale, "server.resetInstruction"),
|
|
||||||
i18n.T(locale, "server.email"),
|
|
||||||
i18n.T(locale, "server.sendLink"),
|
|
||||||
i18n.T(locale, "server.backToLogin"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func forgotSentHTML(locale string) string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
||||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
|
||||||
h1{font-size:18px;margin:0 0 12px;color:#34d399}
|
|
||||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
|
||||||
a{color:#6366f1;text-decoration:none}
|
|
||||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
|
||||||
.btn:hover{background:#4f46e5}</style>
|
|
||||||
</head><body>
|
|
||||||
<div class="box">
|
|
||||||
<h1>%s</h1>
|
|
||||||
<p>%s</p>
|
|
||||||
<a href="/login" class="btn">%s</a>
|
|
||||||
</div>
|
|
||||||
</body></html>`,
|
|
||||||
i18n.T(locale, "server.emailSentTitle"),
|
|
||||||
i18n.T(locale, "server.emailSent"),
|
|
||||||
i18n.T(locale, "server.emailSentMessage"),
|
|
||||||
i18n.T(locale, "server.goHome"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetPasswordHTML(locale string) string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>%s</title>
|
|
||||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
||||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
|
||||||
h1{font-size:18px;margin:0 0 20px;text-align:center}
|
|
||||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
|
||||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
|
||||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
|
||||||
button:hover{background:#4f46e5}
|
|
||||||
.hint{font-size:11px;color:#666;text-align:center;margin-top:12px}</style>
|
|
||||||
</head><body>
|
|
||||||
<form method="POST">
|
|
||||||
<h1>%s</h1>
|
|
||||||
<input type="hidden" name="token" value="{TOKEN}">
|
|
||||||
<label>%s</label>
|
|
||||||
<input type="password" name="password" minlength="8" required autofocus>
|
|
||||||
<label>%s</label>
|
|
||||||
<input type="password" name="confirm" minlength="8" required>
|
|
||||||
<div class="hint">%s</div>
|
|
||||||
<button style="margin-top:8px">%s</button>
|
|
||||||
</form>
|
|
||||||
</body></html>`,
|
|
||||||
i18n.T(locale, "server.newPasswordTitle"),
|
|
||||||
i18n.T(locale, "server.newPassword"),
|
|
||||||
i18n.T(locale, "server.password"),
|
|
||||||
i18n.T(locale, "server.passwordConfirm"),
|
|
||||||
i18n.T(locale, "server.adminPwdHint"),
|
|
||||||
i18n.T(locale, "server.save"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetDoneHTML(locale string) string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Verstak Sync — %s</title>
|
|
||||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
||||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
|
||||||
h1{font-size:18px;margin:0 0 12px;color:#34d399}
|
|
||||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
|
||||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
|
||||||
.btn:hover{background:#4f46e5}</style>
|
|
||||||
</head><body>
|
|
||||||
<div class="box">
|
|
||||||
<h1>%s</h1>
|
|
||||||
<p>%s</p>
|
|
||||||
<a href="/login" class="btn">%s</a>
|
|
||||||
</div>
|
|
||||||
</body></html>`,
|
|
||||||
i18n.T(locale, "server.passwordChanged"),
|
|
||||||
i18n.T(locale, "server.passwordChanged"),
|
|
||||||
i18n.T(locale, "server.passwordChangedMessage"),
|
|
||||||
i18n.T(locale, "server.loginBtn"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func errorPageHTML(locale, title, msg, backURL string) string {
|
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Verstak Sync — %s</title>
|
|
||||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
|
||||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;text-align:center;max-width:360px}
|
|
||||||
h1{font-size:18px;margin:0 0 12px;color:#ff6b6b}
|
|
||||||
p{font-size:13px;color:#b0b0c0;margin:0 0 16px}
|
|
||||||
a{color:#6366f1;text-decoration:none}
|
|
||||||
a:hover{text-decoration:underline}</style>
|
|
||||||
</head><body>
|
|
||||||
<div class="box">
|
|
||||||
<h1>%s</h1>
|
|
||||||
<p>%s</p>
|
|
||||||
<a href="%s">%s</a>
|
|
||||||
</div>
|
|
||||||
</body></html>`, title, title, msg, backURL, i18n.T(locale, "server.back"))
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type tokenStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
tokens map[string]time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTokenStore() *tokenStore {
|
|
||||||
return &tokenStore{tokens: make(map[string]time.Time)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *tokenStore) Create() string {
|
|
||||||
ts.mu.Lock()
|
|
||||||
defer ts.mu.Unlock()
|
|
||||||
b := make([]byte, 16)
|
|
||||||
rand.Read(b)
|
|
||||||
tok := hex.EncodeToString(b)
|
|
||||||
ts.tokens[tok] = time.Now().Add(24 * time.Hour)
|
|
||||||
return tok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *tokenStore) Check(tok string) bool {
|
|
||||||
ts.mu.Lock()
|
|
||||||
defer ts.mu.Unlock()
|
|
||||||
exp, ok := ts.tokens[tok]
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if time.Now().After(exp) {
|
|
||||||
delete(ts.tokens, tok)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// userTokenStore embeds tokenStore but also tracks the user_id per token.
|
|
||||||
type userTokenStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
tokens map[string]userTokenEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
type userTokenEntry struct {
|
|
||||||
UserID string
|
|
||||||
ExpiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func newUserTokenStore() *userTokenStore {
|
|
||||||
return &userTokenStore{tokens: make(map[string]userTokenEntry)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (uts *userTokenStore) Create(userID string) string {
|
|
||||||
uts.mu.Lock()
|
|
||||||
defer uts.mu.Unlock()
|
|
||||||
b := make([]byte, 16)
|
|
||||||
rand.Read(b)
|
|
||||||
tok := hex.EncodeToString(b)
|
|
||||||
uts.tokens[tok] = userTokenEntry{UserID: userID, ExpiresAt: time.Now().Add(24 * time.Hour)}
|
|
||||||
return tok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (uts *userTokenStore) Check(tok string) (string, bool) {
|
|
||||||
uts.mu.Lock()
|
|
||||||
defer uts.mu.Unlock()
|
|
||||||
entry, ok := uts.tokens[tok]
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if time.Now().After(entry.ExpiresAt) {
|
|
||||||
delete(uts.tokens, tok)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return entry.UserID, true
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
:root {
|
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
|
||||||
|
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||||
|
sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: rgba(27, 38, 54, 1);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local(""),
|
||||||
|
url("./Inter-Medium.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 3em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 60px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: none;
|
||||||
|
margin: 0 0 0 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
place-content: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #e80000aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo.vanilla:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #f7df1eaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 1rem;
|
||||||
|
align-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.input-box .btn:hover {
|
||||||
|
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-box .input {
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: black;
|
||||||
|
background-color: rgba(240, 240, 240, 1);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-box .input:hover {
|
||||||
|
border: none;
|
||||||
|
background-color: rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-box .input:focus {
|
||||||
|
border: none;
|
||||||
|
background-color: rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
import ConfirmModal from './lib/ConfirmModal.svelte'
|
import ConfirmModal from './lib/ConfirmModal.svelte'
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
|
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
|
||||||
import { t } from './lib/i18n'
|
|
||||||
|
|
||||||
// ===== Wails v2 API call helper =====
|
// ===== Wails v2 API call helper =====
|
||||||
// In production: window['go']['main']['App']['MethodName'](...args)
|
// In production: window['go']['main']['App']['MethodName'](...args)
|
||||||
|
|
@ -57,13 +56,13 @@
|
||||||
let newActionKind = 'open_url'
|
let newActionKind = 'open_url'
|
||||||
let newActionData = ''
|
let newActionData = ''
|
||||||
let actionKinds = [
|
let actionKinds = [
|
||||||
{ id: 'open_url', label: t('action.openUrl') },
|
{ id: 'open_url', label: 'Открыть URL' },
|
||||||
{ id: 'open_file', label: t('action.openFile') },
|
{ id: 'open_file', label: 'Открыть файл' },
|
||||||
{ id: 'open_folder', label: t('action.openFolder') },
|
{ id: 'open_folder', label: 'Открыть папку' },
|
||||||
{ id: 'run_command', label: t('action.runCommand') },
|
{ id: 'run_command', label: 'Запустить команду' },
|
||||||
{ id: 'run_script', label: t('action.runScript') },
|
{ id: 'run_script', label: 'Запустить скрипт' },
|
||||||
{ id: 'open_terminal', label: t('action.openTerminal') },
|
{ id: 'open_terminal', label: 'Открыть терминал' },
|
||||||
{ id: 'launch_app', label: t('action.launchApp') },
|
{ id: 'launch_app', label: 'Запустить приложение' },
|
||||||
]
|
]
|
||||||
let loading = true
|
let loading = true
|
||||||
let importing = false
|
let importing = false
|
||||||
|
|
@ -90,7 +89,7 @@
|
||||||
let confirmTitle = ''
|
let confirmTitle = ''
|
||||||
let confirmMessage = ''
|
let confirmMessage = ''
|
||||||
let confirmDanger = false
|
let confirmDanger = false
|
||||||
let confirmText = t('common.delete')
|
let confirmText = 'Удалить'
|
||||||
let confirmAction = null
|
let confirmAction = null
|
||||||
let cancelAction = null
|
let cancelAction = null
|
||||||
|
|
||||||
|
|
@ -110,12 +109,12 @@
|
||||||
let syncResult = ''
|
let syncResult = ''
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'overview', label: t('tab.overview') },
|
{ id: 'overview', label: 'Обзор' },
|
||||||
{ id: 'notes', label: t('tab.notes') },
|
{ id: 'notes', label: 'Заметки' },
|
||||||
{ id: 'files', label: t('tab.files') },
|
{ id: 'files', label: 'Файлы' },
|
||||||
{ id: 'actions', label: t('tab.actions') },
|
{ id: 'actions', label: 'Действия' },
|
||||||
{ id: 'worklog', label: t('tab.worklog') },
|
{ id: 'worklog', label: 'Журнал' },
|
||||||
{ id: 'activity', label: t('tab.activity') },
|
{ id: 'activity', label: 'Активность' },
|
||||||
]
|
]
|
||||||
|
|
||||||
let unlistenDrop = null
|
let unlistenDrop = null
|
||||||
|
|
@ -129,14 +128,14 @@
|
||||||
error = String(e)
|
error = String(e)
|
||||||
// Fallback: show sections from known list
|
// Fallback: show sections from known list
|
||||||
sections = [
|
sections = [
|
||||||
{ id: 'today', label: t('nav.today') },
|
{ id: 'today', label: 'Сегодня' },
|
||||||
{ id: 'inbox', label: t('nav.inbox') },
|
{ id: 'inbox', label: 'Неразобранное' },
|
||||||
{ id: 'activity', label: t('nav.activity') },
|
{ id: 'activity', label: 'Активность' },
|
||||||
{ id: 'clients', label: t('nav.clients') },
|
{ id: 'clients', label: 'Клиенты' },
|
||||||
{ id: 'projects', label: t('nav.projects') },
|
{ id: 'projects', label: 'Проекты' },
|
||||||
{ id: 'recipes', label: t('nav.recipes') },
|
{ id: 'recipes', label: 'Рецепты' },
|
||||||
{ id: 'documents', label: t('nav.documents') },
|
{ id: 'documents', label: 'Документы' },
|
||||||
{ id: 'archive', label: t('nav.archive') },
|
{ id: 'archive', label: 'Архив' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,7 +315,7 @@
|
||||||
// ===== File operations =====
|
// ===== File operations =====
|
||||||
|
|
||||||
async function createFile() {
|
async function createFile() {
|
||||||
const name = prompt(t('file.namePrompt'))
|
const name = prompt('Введите имя файла:')
|
||||||
if (!name || !name.trim()) return
|
if (!name || !name.trim()) return
|
||||||
try {
|
try {
|
||||||
const parentId = currentFolderId || selectedNode.id
|
const parentId = currentFolderId || selectedNode.id
|
||||||
|
|
@ -412,19 +411,11 @@
|
||||||
|
|
||||||
async function deleteSelected() {
|
async function deleteSelected() {
|
||||||
const ids = getTargetIds(selectedIds)
|
const ids = getTargetIds(selectedIds)
|
||||||
const item = fileItems.find(x => x.id === ids[0])
|
const label = ids.length === 1 && fileItems.find(x => x.id === ids[0])?.type === 'folder' ? 'папку' : `файлов (${ids.length})`
|
||||||
let label
|
|
||||||
if (ids.length === 1 && item?.type === 'folder') {
|
|
||||||
label = t('delete.folder')
|
|
||||||
} else if (ids.length === 1) {
|
|
||||||
label = t('delete.file')
|
|
||||||
} else {
|
|
||||||
label = t('delete.files', { count: ids.length })
|
|
||||||
}
|
|
||||||
openConfirm({
|
openConfirm({
|
||||||
title: t('delete.confirmTitle'),
|
title: 'Удаление',
|
||||||
message: t('delete.confirmMessage') + ' ' + label + '?',
|
message: `Удалить ${label}?`,
|
||||||
confirmText: t('common.delete'),
|
confirmText: 'Удалить',
|
||||||
danger: true,
|
danger: true,
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
|
|
@ -557,11 +548,12 @@
|
||||||
|
|
||||||
async function submitRename() {
|
async function submitRename() {
|
||||||
const name = renameValue.trim()
|
const name = renameValue.trim()
|
||||||
if (!name) { renameError = t('rename.emptyError'); return }
|
if (!name) { renameError = 'Имя не может быть пустым'; return }
|
||||||
|
// Validate name via backend
|
||||||
try {
|
try {
|
||||||
await wailsCall('ValidateName', name)
|
await wailsCall('ValidateName', name)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
renameError = t('rename.invalidError')
|
renameError = 'Недопустимое имя'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showRename = false
|
showRename = false
|
||||||
|
|
@ -590,10 +582,10 @@
|
||||||
// ===== Confirm modal =====
|
// ===== Confirm modal =====
|
||||||
|
|
||||||
function openConfirm(opts) {
|
function openConfirm(opts) {
|
||||||
confirmTitle = opts.title || t('common.confirm')
|
confirmTitle = opts.title || 'Подтверждение'
|
||||||
confirmMessage = opts.message || ''
|
confirmMessage = opts.message || ''
|
||||||
confirmDanger = opts.danger !== undefined ? opts.danger : true
|
confirmDanger = opts.danger !== undefined ? opts.danger : true
|
||||||
confirmText = opts.confirmText || t('common.delete')
|
confirmText = opts.confirmText || 'Удалить'
|
||||||
confirmAction = opts.onConfirm || null
|
confirmAction = opts.onConfirm || null
|
||||||
cancelAction = opts.onCancel || null
|
cancelAction = opts.onCancel || null
|
||||||
showConfirm = true
|
showConfirm = true
|
||||||
|
|
@ -662,9 +654,9 @@
|
||||||
async function openNote(note) {
|
async function openNote(note) {
|
||||||
if (noteEditor && noteEditor.dirty) {
|
if (noteEditor && noteEditor.dirty) {
|
||||||
openConfirm({
|
openConfirm({
|
||||||
title: t('note.unsavedTitle'),
|
title: 'Несохранённые изменения',
|
||||||
message: t('note.unsavedMessage'),
|
message: 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
|
||||||
confirmText: t('note.unsavedClose'),
|
confirmText: 'Закрыть',
|
||||||
danger: false,
|
danger: false,
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
await doOpenNote(note)
|
await doOpenNote(note)
|
||||||
|
|
@ -687,9 +679,9 @@
|
||||||
function closeNoteEditor() {
|
function closeNoteEditor() {
|
||||||
if (noteEditor && noteEditor.dirty) {
|
if (noteEditor && noteEditor.dirty) {
|
||||||
openConfirm({
|
openConfirm({
|
||||||
title: t('note.unsavedTitle'),
|
title: 'Несохранённые изменения',
|
||||||
message: t('note.unsavedMessage'),
|
message: 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
|
||||||
confirmText: t('note.unsavedClose'),
|
confirmText: 'Закрыть',
|
||||||
danger: false,
|
danger: false,
|
||||||
onConfirm: () => { noteEditor = null }
|
onConfirm: () => { noteEditor = null }
|
||||||
})
|
})
|
||||||
|
|
@ -777,11 +769,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFile({ id, type }) {
|
async function deleteFile({ id, type }) {
|
||||||
const label = type === 'folder' ? t('delete.folder') : t('delete.file')
|
const label = type === 'folder' ? 'папку' : 'файл'
|
||||||
openConfirm({
|
openConfirm({
|
||||||
title: t('delete.confirmTitle'),
|
title: 'Удаление',
|
||||||
message: t('delete.confirmMessage') + ' ' + label + '?',
|
message: `Удалить ${label}?`,
|
||||||
confirmText: t('common.delete'),
|
confirmText: 'Удалить',
|
||||||
danger: true,
|
danger: true,
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -808,7 +800,7 @@
|
||||||
async function onFilesDropped(paths) {
|
async function onFilesDropped(paths) {
|
||||||
if (!paths || paths.length === 0) return
|
if (!paths || paths.length === 0) return
|
||||||
if (!selectedNode) {
|
if (!selectedNode) {
|
||||||
error = t('error.selectCaseFirst')
|
error = 'Сначала выберите дело для добавления файлов'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const path = paths[0]
|
const path = paths[0]
|
||||||
|
|
@ -819,18 +811,18 @@
|
||||||
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
|
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
|
||||||
function eventLabel(type) {
|
function eventLabel(type) {
|
||||||
const labels = {
|
const labels = {
|
||||||
'note_created': t('event.noteCreated'),
|
'note_created': 'Заметка создана',
|
||||||
'note_updated': t('event.noteUpdated'),
|
'note_updated': 'Заметка изменена',
|
||||||
'file_added': t('event.fileAdded'),
|
'file_added': 'Файл добавлен',
|
||||||
'file_deleted': t('event.fileDeleted'),
|
'file_deleted': 'Файл удалён',
|
||||||
'file_renamed': t('event.fileRenamed'),
|
'file_renamed': 'Файл переименован',
|
||||||
'file_copied': t('event.fileCopied'),
|
'file_copied': 'Файл скопирован',
|
||||||
'file_moved': t('event.fileMoved'),
|
'file_moved': 'Файл перемещён',
|
||||||
'folder_added': t('event.folderAdded'),
|
'folder_added': 'Папка добавлена',
|
||||||
'folder_deleted': t('event.folderDeleted'),
|
'folder_deleted': 'Папка удалена',
|
||||||
'folder_renamed': t('event.folderRenamed'),
|
'folder_renamed': 'Папка переименована',
|
||||||
'node_created': t('event.caseCreated'),
|
'node_created': 'Дело создано',
|
||||||
'node_updated': t('event.caseUpdated'),
|
'node_updated': 'Дело изменено',
|
||||||
}
|
}
|
||||||
return labels[type] || type
|
return labels[type] || type
|
||||||
}
|
}
|
||||||
|
|
@ -851,8 +843,8 @@
|
||||||
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
|
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
|
||||||
}
|
}
|
||||||
function nodeKindLabel(kind) {
|
function nodeKindLabel(kind) {
|
||||||
const labels = { 'project': t('kind.project'), 'client': t('kind.client'), 'document': t('kind.document'), 'recipe': t('kind.recipe'), 'archive': t('kind.archive'), 'case': t('kind.case') }
|
const labels = { 'project': 'Проект', 'client': 'Клиент', 'document': 'Документ', 'recipe': 'Рецепт', 'archive': 'Архив', 'case': 'Дело' }
|
||||||
return labels[kind] || kind || t('kind.case')
|
return labels[kind] || kind || 'Дело'
|
||||||
}
|
}
|
||||||
function pluralize(n, one, few, many) {
|
function pluralize(n, one, few, many) {
|
||||||
n = Math.abs(n) % 100
|
n = Math.abs(n) % 100
|
||||||
|
|
@ -948,7 +940,7 @@
|
||||||
syncResult = ''
|
syncResult = ''
|
||||||
try {
|
try {
|
||||||
await wailsCall('SyncSetInterval', syncInterval)
|
await wailsCall('SyncSetInterval', syncInterval)
|
||||||
syncResult = t('sync.settingsSaved')
|
syncResult = 'интервал сохранён'
|
||||||
await loadSyncStatus()
|
await loadSyncStatus()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
syncResult = 'err: ' + String(e)
|
syncResult = 'err: ' + String(e)
|
||||||
|
|
@ -1000,11 +992,11 @@
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<span class="logo">⚒</span>
|
<span class="logo">⚒</span>
|
||||||
<span class="brand-name">{t('nav.brand')}</span>
|
<span class="brand-name">Верстак</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">{t('nav.sections')}</div>
|
<div class="nav-label">Разделы</div>
|
||||||
{#each sections as section}
|
{#each sections as section}
|
||||||
<button class="nav-item {selectedSection === section.id ? 'selected' : ''}"
|
<button class="nav-item {selectedSection === section.id ? 'selected' : ''}"
|
||||||
on:click={() => selectSection(section.id)}>
|
on:click={() => selectSection(section.id)}>
|
||||||
|
|
@ -1014,22 +1006,22 @@
|
||||||
</div>
|
</div>
|
||||||
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">{t('nav.cases')} {#if nodes.length > 0}({nodes.length}){/if}</div>
|
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
|
||||||
{#each nodes as node}
|
{#each nodes as node}
|
||||||
<button class="nav-item {selectedNode && selectedNode.id === node.id ? 'selected' : ''}"
|
<button class="nav-item {selectedNode && selectedNode.id === node.id ? 'selected' : ''}"
|
||||||
on:click={() => selectNode(node)}>
|
on:click={() => selectNode(node)}>
|
||||||
{node.title}
|
{node.title}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if nodes.length === 0}<div class="nav-empty">{t('nav.noCases')}</div>{/if}
|
{#if nodes.length === 0}<div class="nav-empty">Нет дел</div>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="sidebar-sync-btn" on:click={openSettings} title={t('nav.syncSettings')}>
|
<button class="sidebar-sync-btn" on:click={openSettings} title="Настройки синхронизации">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||||
<span class="sync-dot" class:active={syncStatus?.configured}></span>
|
<span class="sync-dot" class:active={syncStatus?.configured}></span>
|
||||||
<span class="sidebar-sync-label">{t('nav.sync')}</span>
|
<span class="sidebar-sync-label">Синхронизация</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="version">{version}</span>
|
<span class="version">{version}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1045,12 +1037,12 @@
|
||||||
{:else if selectedSection}
|
{:else if selectedSection}
|
||||||
<span class="crumb">{#each sections as s}{s.id === selectedSection ? s.label : ''}{/each}</span>
|
<span class="crumb">{#each sections as s}{s.id === selectedSection ? s.label : ''}{/each}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="crumb placeholder">{t('nav.selectPrompt')}</span>
|
<span class="crumb placeholder">Выберите раздел или дело</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
{#if syncStatus?.configured}
|
{#if syncStatus?.configured}
|
||||||
<button class="header-sync-btn" on:click={runSyncNow} disabled={syncLoading} title={t('nav.syncNow')}>
|
<button class="header-sync-btn" on:click={runSyncNow} disabled={syncLoading} title="Синхронизировать">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||||
{#if syncStatus.unpushedOps > 0}
|
{#if syncStatus.unpushedOps > 0}
|
||||||
<span class="sync-badge">{syncStatus.unpushedOps}</span>
|
<span class="sync-badge">{syncStatus.unpushedOps}</span>
|
||||||
|
|
@ -1078,12 +1070,12 @@
|
||||||
<span class="note-title">{noteEditor.title}</span>
|
<span class="note-title">{noteEditor.title}</span>
|
||||||
{#if noteEditor.dirty}<span class="dirty-mark">●</span>{/if}
|
{#if noteEditor.dirty}<span class="dirty-mark">●</span>{/if}
|
||||||
<div class="note-editor-actions">
|
<div class="note-editor-actions">
|
||||||
<button class="btn btn-primary" on:click={saveCurrentNote}>{t('common.save')}</button>
|
<button class="btn btn-primary" on:click={saveCurrentNote}>Сохранить</button>
|
||||||
<button class="btn" on:click={closeNoteEditor}>{t('common.close')}</button>
|
<button class="btn" on:click={closeNoteEditor}>Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea class="note-textarea" bind:value={noteEditor.content}
|
<textarea class="note-textarea" bind:value={noteEditor.content}
|
||||||
on:input={updateNoteContent} placeholder={t('note.placeholder')}></textarea>
|
on:input={updateNoteContent} placeholder="Начните писать..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if selectedNode}
|
{:else if selectedNode}
|
||||||
|
|
@ -1098,31 +1090,31 @@
|
||||||
<div class="overview">
|
<div class="overview">
|
||||||
<h2>{selectedNode.title}</h2>
|
<h2>{selectedNode.title}</h2>
|
||||||
<div class="meta-grid">
|
<div class="meta-grid">
|
||||||
<div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{selectedNode.type}</span></div>
|
<div class="meta-item"><span class="meta-label">Тип</span><span>{selectedNode.type}</span></div>
|
||||||
<div class="meta-item"><span class="meta-label">{t('overview.section')}</span><span>{selectedNode.section || '—'}</span></div>
|
<div class="meta-item"><span class="meta-label">Раздел</span><span>{selectedNode.section || '—'}</span></div>
|
||||||
<div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(selectedNode.createdAt)}</span></div>
|
<div class="meta-item"><span class="meta-label">Создано</span><span>{formatDate(selectedNode.createdAt)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<button class="qa-btn" on:click={() => { activeTab = 'notes'; openCreateNote() }}>
|
<button class="qa-btn" on:click={() => { activeTab = 'notes'; openCreateNote() }}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
{t('overview.newNote')}
|
Новая заметка
|
||||||
</button>
|
</button>
|
||||||
<button class="qa-btn" on:click={() => { activeTab = 'files'; addFile() }}>
|
<button class="qa-btn" on:click={() => { activeTab = 'files'; addFile() }}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
{t('overview.addFile')}
|
Добавить файл
|
||||||
</button>
|
</button>
|
||||||
<button class="qa-btn" on:click={openCreateAction}>
|
<button class="qa-btn" on:click={openCreateAction}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||||
{t('overview.addAction')}
|
Добавить действие
|
||||||
</button>
|
</button>
|
||||||
<button class="qa-btn" on:click={() => activeTab = 'worklog'}>
|
<button class="qa-btn" on:click={() => activeTab = 'worklog'}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
{t('overview.logTime')}
|
Записать время
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if notes.length > 0}
|
{#if notes.length > 0}
|
||||||
<div class="recent-section">
|
<div class="recent-section">
|
||||||
<h3>{t('overview.recentNotes')}</h3>
|
<h3>Последние заметки</h3>
|
||||||
{#each notes.slice(0, 5) as note}
|
{#each notes.slice(0, 5) as note}
|
||||||
<div class="recent-note" on:click={() => openNote(note)}>
|
<div class="recent-note" on:click={() => openNote(note)}>
|
||||||
<span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span>
|
<span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span>
|
||||||
|
|
@ -1132,9 +1124,9 @@
|
||||||
{/if}
|
{/if}
|
||||||
{#if worklog.length > 0}
|
{#if worklog.length > 0}
|
||||||
<div class="recent-section">
|
<div class="recent-section">
|
||||||
<h3>{t('overview.recentEntries')}</h3>
|
<h3>Последние записи</h3>
|
||||||
{#each worklog.slice(0, 3) as e}
|
{#each worklog.slice(0, 3) as e}
|
||||||
<div class="recent-entry">{e.summary} ({e.minutes} {t('worklog.min')})</div>
|
<div class="recent-entry">{e.summary} ({e.minutes} мин)</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -1143,20 +1135,20 @@
|
||||||
{:else if activeTab === 'notes'}
|
{:else if activeTab === 'notes'}
|
||||||
<div class="notes-tab">
|
<div class="notes-tab">
|
||||||
<div class="tab-toolbar">
|
<div class="tab-toolbar">
|
||||||
<button class="btn btn-primary" on:click={openCreateNote}>{t('note.add')}</button>
|
<button class="btn btn-primary" on:click={openCreateNote}>+ Добавить заметку</button>
|
||||||
</div>
|
</div>
|
||||||
{#if showCreateNote}
|
{#if showCreateNote}
|
||||||
<div class="create-form">
|
<div class="create-form">
|
||||||
<input type="text" placeholder={t('note.title')} bind:value={newNoteTitle}
|
<input type="text" placeholder="Название заметки" bind:value={newNoteTitle}
|
||||||
on:keydown={(e) => e.key === 'Enter' && submitCreateNote()} />
|
on:keydown={(e) => e.key === 'Enter' && submitCreateNote()} />
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn btn-primary" on:click={submitCreateNote}>{t('common.create')}</button>
|
<button class="btn btn-primary" on:click={submitCreateNote}>Создать</button>
|
||||||
<button class="btn" on:click={cancelCreateNote}>{t('common.cancel')}</button>
|
<button class="btn" on:click={cancelCreateNote}>Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if notes.length === 0 && !showCreateNote}
|
{#if notes.length === 0 && !showCreateNote}
|
||||||
<div class="empty-state"><p>{t('note.noNotes')}</p><p class="hint">{t('note.createFirst')}</p></div>
|
<div class="empty-state"><p>Нет заметок</p><p class="hint">Создайте первую заметку для этого дела.</p></div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="notes-list">
|
<div class="notes-list">
|
||||||
{#each notes as note}
|
{#each notes as note}
|
||||||
|
|
@ -1172,21 +1164,21 @@
|
||||||
{:else if activeTab === 'files'}
|
{:else if activeTab === 'files'}
|
||||||
<div class="files-tab">
|
<div class="files-tab">
|
||||||
<div class="tab-toolbar">
|
<div class="tab-toolbar">
|
||||||
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
|
<button class="btn btn-primary" on:click={addFile} disabled={importing}>+ Добавить файл</button>
|
||||||
<button class="btn" on:click={addFolder} disabled={importing}>{t('file.addFolder')}</button>
|
<button class="btn" on:click={addFolder} disabled={importing}>+ Добавить папку</button>
|
||||||
<button class="btn" on:click={createFile}>{t('file.newFile')}</button>
|
<button class="btn" on:click={createFile}>+ Новый файл</button>
|
||||||
{#if clipboard.items.length > 0}
|
{#if clipboard.items.length > 0}
|
||||||
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
|
<button class="btn" on:click={pasteItem}>Вставить {clipboard.items.length}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loadingFiles}
|
{#if loadingFiles}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>{t('common.loading')}</p>
|
<p>Загрузка...</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if folderStack.length > 0}
|
{#if folderStack.length > 0}
|
||||||
<FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...folderStack]} on:navigate={(e) => {
|
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => {
|
||||||
const i = e.detail
|
const i = e.detail
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
folderStack = []
|
folderStack = []
|
||||||
|
|
@ -1198,10 +1190,10 @@
|
||||||
}}/>
|
}}/>
|
||||||
<button class="btn btn-sm back-btn" on:click={navigateBack}>
|
<button class="btn btn-sm back-btn" on:click={navigateBack}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||||
{t('common.back')}
|
Back
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
|
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if fileItems.length === 0}
|
{#if fileItems.length === 0}
|
||||||
|
|
@ -1212,11 +1204,11 @@
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p>{folderStack.length > 0 ? t('file.noFiles') : t('file.noFilesCase')}</p>
|
<p>{folderStack.length > 0 ? 'В этой папке пока нет файлов' : 'В этом проекте пока нет файлов'}</p>
|
||||||
<p class="hint">{t('file.hint')}</p>
|
<p class="hint">Добавьте файл или папку, чтобы сохранить материалы проекта.</p>
|
||||||
<div class="empty-actions">
|
<div class="empty-actions">
|
||||||
<button class="btn btn-primary" on:click={addFile}>{t('file.addFileSimple')}</button>
|
<button class="btn btn-primary" on:click={addFile}>Добавить файл</button>
|
||||||
<button class="btn" on:click={addFolder}>{t('file.addFolderSimple')}</button>
|
<button class="btn" on:click={addFolder}>Добавить папку</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -1247,7 +1239,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if importing && !showImportDialog}
|
{#if importing && !showImportDialog}
|
||||||
<div class="empty-state"><p>{t('file.scanning')}</p></div>
|
<div class="empty-state"><p>Сканирование...</p></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1265,10 +1257,10 @@
|
||||||
{:else if activeTab === 'actions'}
|
{:else if activeTab === 'actions'}
|
||||||
<div class="actions-tab">
|
<div class="actions-tab">
|
||||||
<div class="tab-toolbar">
|
<div class="tab-toolbar">
|
||||||
<button class="btn btn-primary" on:click={openCreateAction}>{t('action.addAction')}</button>
|
<button class="btn btn-primary" on:click={openCreateAction}>+ Добавить действие</button>
|
||||||
</div>
|
</div>
|
||||||
{#if actions.length === 0}
|
{#if actions.length === 0}
|
||||||
<div class="empty-state"><p>{t('action.noActions')}</p></div>
|
<div class="empty-state"><p>Действий пока нет</p></div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each actions as action}
|
{#each actions as action}
|
||||||
<div class="action-card">
|
<div class="action-card">
|
||||||
|
|
@ -1278,7 +1270,7 @@
|
||||||
<span class="action-data">{action.data}</span>
|
<span class="action-data">{action.data}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-btns">
|
<div class="action-btns">
|
||||||
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>{t('action.run')}</button>
|
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
|
||||||
<button class="btn btn-sm btn-danger" on:click={() => deleteAction(action.id)}>
|
<button class="btn btn-sm btn-danger" on:click={() => deleteAction(action.id)}>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1291,18 +1283,18 @@
|
||||||
{:else if activeTab === 'worklog'}
|
{:else if activeTab === 'worklog'}
|
||||||
<div class="worklog-tab">
|
<div class="worklog-tab">
|
||||||
<div class="worklog-form">
|
<div class="worklog-form">
|
||||||
<input type="text" placeholder={t('worklog.whatDone')} bind:value={worklogSummary} />
|
<input type="text" placeholder="Что сделано" bind:value={worklogSummary} />
|
||||||
<input type="number" placeholder={t('worklog.minutes')} bind:value={worklogMinutes} min="1" />
|
<input type="number" placeholder="Мин" bind:value={worklogMinutes} min="1" />
|
||||||
<button class="btn btn-primary" on:click={submitWorklog}
|
<button class="btn btn-primary" on:click={submitWorklog}
|
||||||
disabled={!worklogSummary.trim() || !worklogMinutes}>{t('worklog.log')}</button>
|
disabled={!worklogSummary.trim() || !worklogMinutes}>Записать</button>
|
||||||
</div>
|
</div>
|
||||||
{#if worklog.length === 0}
|
{#if worklog.length === 0}
|
||||||
<div class="empty-state"><p>{t('worklog.empty')}</p></div>
|
<div class="empty-state"><p>Записей работы пока нет</p></div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each worklog as e}
|
{#each worklog as e}
|
||||||
<div class="worklog-entry">
|
<div class="worklog-entry">
|
||||||
<div>{e.summary}</div>
|
<div>{e.summary}</div>
|
||||||
<div class="wl-meta">{e.minutes} {t('worklog.min')} · {formatDate(e.createdAt)}</div>
|
<div class="wl-meta">{e.minutes} мин · {formatDate(e.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -1311,7 +1303,7 @@
|
||||||
{:else if activeTab === 'activity'}
|
{:else if activeTab === 'activity'}
|
||||||
<div class="activity-tab">
|
<div class="activity-tab">
|
||||||
{#if caseActivity.length === 0}
|
{#if caseActivity.length === 0}
|
||||||
<div class="empty-state"><p>{t('activity.perCaseEmpty')}</p></div>
|
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="activity-events">
|
<div class="activity-events">
|
||||||
{#each caseActivity as ev}
|
{#each caseActivity as ev}
|
||||||
|
|
@ -1332,14 +1324,14 @@
|
||||||
{:else if selectedSection === 'today' && todayDashboard}
|
{:else if selectedSection === 'today' && todayDashboard}
|
||||||
<div class="today-dashboard">
|
<div class="today-dashboard">
|
||||||
<div class="today-header">
|
<div class="today-header">
|
||||||
<h2>{t('today.title')}</h2>
|
<h2>Сегодня</h2>
|
||||||
<span class="today-date">{todayDashboard.date}</span>
|
<span class="today-date">{todayDashboard.date}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if todayDashboard.summary}
|
{#if todayDashboard.summary}
|
||||||
<div class="today-summary">
|
<div class="today-summary">
|
||||||
{#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, t('today.plural.case_one'), t('today.plural.case_few'), t('today.plural.case_many'))}</span>{/if}
|
{#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, 'дело', 'дела', 'дел')}</span>{/if}
|
||||||
{#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, t('today.plural.note_one'), t('today.plural.note_few'), t('today.plural.note_many'))}</span>{/if}
|
{#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, 'заметка', 'заметки', 'заметок')}</span>{/if}
|
||||||
{#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, t('today.plural.file_one'), t('today.plural.file_few'), t('today.plural.file_many'))}</span>{/if}
|
{#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, 'файл', 'файла', 'файлов')}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -1349,7 +1341,7 @@
|
||||||
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
||||||
<span class="today-case-title">{group.nodeTitle}</span>
|
<span class="today-case-title">{group.nodeTitle}</span>
|
||||||
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
|
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
|
||||||
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, t('today.plural.event_one'), t('today.plural.event_few'), t('today.plural.event_many'))}</span>{/if}
|
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, 'событие', 'события', 'событий')}</span>{/if}
|
||||||
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
|
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if group.events && group.events.length > 0}
|
{#if group.events && group.events.length > 0}
|
||||||
|
|
@ -1365,14 +1357,14 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="today-events-empty">{t('today.changedCases')}</div>
|
<div class="today-events-empty">Изменён сегодня</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if todayDashboard.events && todayDashboard.events.length > 0}
|
{#if todayDashboard.events && todayDashboard.events.length > 0}
|
||||||
<div class="today-timeline">
|
<div class="today-timeline">
|
||||||
<h3>{t('today.timeline')}</h3>
|
<h3>Лента за сегодня</h3>
|
||||||
{#each todayDashboard.events as ev}
|
{#each todayDashboard.events as ev}
|
||||||
<div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
<div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||||||
<span class="timeline-dot"></span>
|
<span class="timeline-dot"></span>
|
||||||
|
|
@ -1385,8 +1377,8 @@
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="today-empty">
|
<div class="today-empty">
|
||||||
<p>{t('today.empty')}</p>
|
<p>Сегодня пока тихо</p>
|
||||||
<p class="hint">{t('today.emptyHint')}</p>
|
<p class="hint">Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1394,10 +1386,10 @@
|
||||||
{:else if selectedSection === 'activity'}
|
{:else if selectedSection === 'activity'}
|
||||||
<div class="activity-feed">
|
<div class="activity-feed">
|
||||||
<div class="activity-feed-header">
|
<div class="activity-feed-header">
|
||||||
<h2>{t('activity.title')}</h2>
|
<h2>Активность</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if activityFeed.length === 0}
|
{#if activityFeed.length === 0}
|
||||||
<div class="empty-state"><p>{t('activity.empty')}</p></div>
|
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="activity-feed-events">
|
<div class="activity-feed-events">
|
||||||
{#each activityFeed as ev}
|
{#each activityFeed as ev}
|
||||||
|
|
@ -1419,30 +1411,30 @@
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="welcome">
|
<div class="welcome">
|
||||||
<h2>{t('welcome.title')}</h2>
|
<h2>Верстак</h2>
|
||||||
{#if loading}<p>{t('common.loading')}</p>
|
{#if loading}<p>Загрузка...</p>
|
||||||
{:else if sections.length > 0}
|
{:else if sections.length > 0}
|
||||||
<p>{t('welcome.selectSection')}</p>
|
<p>Выберите раздел в боковой панели.</p>
|
||||||
<p class="hint">{t('welcome.createCase')}</p>
|
<p class="hint">Или создайте новое дело кнопкой «+».</p>
|
||||||
{:else if error}<p class="error-text">{t('common.error')} {error}</p>{/if}
|
{:else if error}<p class="error-text">Ошибка: {error}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||||
<div class="fab" on:click={openCreateNode} title={t('welcome.addCase')}>+</div>
|
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showCreateNode}
|
{#if showCreateNode}
|
||||||
<div class="modal-overlay" on:click|self={cancelCreateNode}>
|
<div class="modal-overlay" on:click|self={cancelCreateNode}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>{t('case.new')}</h3>
|
<h3>Новое дело</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{t('common.name')}</label>
|
<label>Название</label>
|
||||||
<input type="text" placeholder={t('case.namePlaceholder')} bind:value={newNodeTitle}
|
<input type="text" placeholder="Название дела" bind:value={newNodeTitle}
|
||||||
on:keydown={(e) => e.key === 'Enter' && submitCreateNode()} autofocus />
|
on:keydown={(e) => e.key === 'Enter' && submitCreateNode()} autofocus />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{t('common.section')}</label>
|
<label>Раздел</label>
|
||||||
<select bind:value={newNodeSection}>
|
<select bind:value={newNodeSection}>
|
||||||
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox' && s.id !== 'activity') as s}
|
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox' && s.id !== 'activity') as s}
|
||||||
<option value={s.id}>{s.label}</option>
|
<option value={s.id}>{s.label}</option>
|
||||||
|
|
@ -1451,9 +1443,9 @@
|
||||||
</div>
|
</div>
|
||||||
{#if templates.length > 0}
|
{#if templates.length > 0}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{t('template.optional')}</label>
|
<label>Шаблон (опционально)</label>
|
||||||
<select bind:value={newNodeTemplate}>
|
<select bind:value={newNodeTemplate}>
|
||||||
<option value="">{t('template.optionNone')}</option>
|
<option value="">Без шаблона</option>
|
||||||
{#each templates as t}
|
{#each templates as t}
|
||||||
<option value={t.name}>{t.name}{t.description ? ' — ' + t.description : ''}</option>
|
<option value={t.name}>{t.name}{t.description ? ' — ' + t.description : ''}</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -1461,8 +1453,8 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" on:click={submitCreateNode}>{t('common.create')}</button>
|
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button>
|
||||||
<button class="btn" on:click={cancelCreateNode}>{t('common.cancel')}</button>
|
<button class="btn" on:click={cancelCreateNode}>Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1471,14 +1463,14 @@
|
||||||
{#if showCreateAction}
|
{#if showCreateAction}
|
||||||
<div class="modal-overlay" on:click|self={cancelCreateAction}>
|
<div class="modal-overlay" on:click|self={cancelCreateAction}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>{t('action.newAction')}</h3>
|
<h3>Новое действие</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{t('common.name')}</label>
|
<label>Название</label>
|
||||||
<input type="text" placeholder={t('action.namePlaceholder')} bind:value={newActionTitle}
|
<input type="text" placeholder="Например: Открыть сайт" bind:value={newActionTitle}
|
||||||
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} autofocus />
|
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} autofocus />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{t('common.type')}</label>
|
<label>Тип</label>
|
||||||
<select bind:value={newActionKind}>
|
<select bind:value={newActionKind}>
|
||||||
{#each actionKinds as k}
|
{#each actionKinds as k}
|
||||||
<option value={k.id}>{k.label}</option>
|
<option value={k.id}>{k.label}</option>
|
||||||
|
|
@ -1486,14 +1478,14 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{newActionKind === 'open_url' ? t('action.dataUrl') : newActionKind === 'open_folder' || newActionKind === 'open_file' ? t('action.dataPath') : t('action.dataCommand')}</label>
|
<label>{newActionKind === 'open_url' ? 'URL' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? 'Путь' : 'Команда'}</label>
|
||||||
<input type="text" placeholder={newActionKind === 'open_url' ? t('action.urlPlaceholder') : newActionKind === 'open_folder' || newActionKind === 'open_file' ? t('action.pathPlaceholder') : t('action.commandPlaceholder')}
|
<input type="text" placeholder={newActionKind === 'open_url' ? 'https://example.com' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? '/path/to/file' : 'команда'}
|
||||||
bind:value={newActionData}
|
bind:value={newActionData}
|
||||||
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} />
|
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" on:click={submitCreateAction}>{t('common.create')}</button>
|
<button class="btn btn-primary" on:click={submitCreateAction}>Создать</button>
|
||||||
<button class="btn" on:click={cancelCreateAction}>{t('common.cancel')}</button>
|
<button class="btn" on:click={cancelCreateAction}>Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1502,11 +1494,11 @@
|
||||||
{#if showImportDialog && importSummary}
|
{#if showImportDialog && importSummary}
|
||||||
<div class="modal-overlay" on:click|self={cancelImport}>
|
<div class="modal-overlay" on:click|self={cancelImport}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>{t('file.importTitle')} «{selectedNode ? selectedNode.title : ''}»</h3>
|
<h3>Добавить в «{selectedNode ? selectedNode.title : ''}»</h3>
|
||||||
<div class="import-summary">
|
<div class="import-summary">
|
||||||
<div class="summary-row"><span>{t('file.importFiles')}</span><span>{importSummary.files}</span></div>
|
<div class="summary-row"><span>Файлов:</span><span>{importSummary.files}</span></div>
|
||||||
<div class="summary-row"><span>{t('file.importFolders')}</span><span>{importSummary.folders}</span></div>
|
<div class="summary-row"><span>Папок:</span><span>{importSummary.folders}</span></div>
|
||||||
<div class="summary-row"><span>{t('file.importSize')}</span><span>{(importSummary.totalBytes / 1024).toFixed(1)} KB</span></div>
|
<div class="summary-row"><span>Размер:</span><span>{(importSummary.totalBytes / 1024).toFixed(1)} KB</span></div>
|
||||||
{#if importSummary.isDangerous}
|
{#if importSummary.isDangerous}
|
||||||
<div class="summary-warn">
|
<div class="summary-warn">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
|
|
@ -1515,9 +1507,9 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>{t('file.importCopy')}</button>
|
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>Скопировать</button>
|
||||||
<button class="btn" on:click={() => confirmImport('link')}>{t('file.importLink')}</button>
|
<button class="btn" on:click={() => confirmImport('link')}>Привязать</button>
|
||||||
<button class="btn" on:click={cancelImport}>{t('common.cancel')}</button>
|
<button class="btn" on:click={cancelImport}>Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1526,9 +1518,9 @@
|
||||||
{#if showRename}
|
{#if showRename}
|
||||||
<div class="modal-overlay" on:click|self={cancelRename}>
|
<div class="modal-overlay" on:click|self={cancelRename}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>{t('rename.title')}</h3>
|
<h3>Переименовать</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{t('common.newName')}</label>
|
<label>Новое имя</label>
|
||||||
<input type="text" bind:value={renameValue}
|
<input type="text" bind:value={renameValue}
|
||||||
on:keydown={onRenameKeydown} />
|
on:keydown={onRenameKeydown} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1536,8 +1528,8 @@
|
||||||
<div class="rename-error">{renameError}</div>
|
<div class="rename-error">{renameError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" on:click={submitRename}>{t('common.rename')}</button>
|
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button>
|
||||||
<button class="btn" on:click={cancelRename}>{t('common.cancel')}</button>
|
<button class="btn" on:click={cancelRename}>Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1557,69 +1549,69 @@
|
||||||
{#if showSettings}
|
{#if showSettings}
|
||||||
<div class="modal-overlay" on:click|self={closeSettings}>
|
<div class="modal-overlay" on:click|self={closeSettings}>
|
||||||
<div class="modal modal-sync">
|
<div class="modal modal-sync">
|
||||||
<h3>{t('sync.settings')}</h3>
|
<h3>Настройки синхронизации</h3>
|
||||||
{#if syncStatus}
|
{#if syncStatus}
|
||||||
<div class="sync-status">
|
<div class="sync-status">
|
||||||
<div class="sync-row">
|
<div class="sync-row">
|
||||||
<span class="sync-label">{t('sync.status')}</span>
|
<span class="sync-label">Статус</span>
|
||||||
<span class="sync-value">
|
<span class="sync-value">
|
||||||
{#if syncStatus.revoked}
|
{#if syncStatus.revoked}
|
||||||
<span style="color:#ff6b6b">{t('sync.revoked')}</span>
|
<span style="color:#ff6b6b">Отозвано</span>
|
||||||
{:else if syncStatus.connected}
|
{:else if syncStatus.connected}
|
||||||
<span style="color:#34d399">{t('sync.connected')}</span>
|
<span style="color:#34d399">Подключено</span>
|
||||||
{:else if syncStatus.configured}
|
{:else if syncStatus.configured}
|
||||||
<span style="color:#f59e0b">{t('sync.notConnected')}</span>
|
<span style="color:#f59e0b">Не подключено</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span style="color:#666">{t('sync.disabled')}</span>
|
<span style="color:#666">Отключена</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if syncStatus.serverUrl}
|
{#if syncStatus.serverUrl}
|
||||||
<div class="sync-row"><span class="sync-label">{t('sync.server')}</span><span class="sync-value mono">{syncStatus.serverUrl}</span></div>
|
<div class="sync-row"><span class="sync-label">Сервер</span><span class="sync-value mono">{syncStatus.serverUrl}</span></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if syncStatus.deviceName}
|
{#if syncStatus.deviceName}
|
||||||
<div class="sync-row"><span class="sync-label">{t('sync.device')}</span><span class="sync-value">{syncStatus.deviceName}</span></div>
|
<div class="sync-row"><span class="sync-label">Устройство</span><span class="sync-value">{syncStatus.deviceName}</span></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if syncStatus.deviceId && !syncStatus.deviceName}
|
{#if syncStatus.deviceId && !syncStatus.deviceName}
|
||||||
<div class="sync-row"><span class="sync-label">{t('sync.deviceId')}</span><span class="sync-value mono">{syncStatus.deviceId}</span></div>
|
<div class="sync-row"><span class="sync-label">ID устройства</span><span class="sync-value mono">{syncStatus.deviceId}</span></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="sync-row"><span class="sync-label">{t('sync.unpushed')}</span><span class="sync-value">{syncStatus.unpushedOps}</span></div>
|
<div class="sync-row"><span class="sync-label">Неотправлено</span><span class="sync-value">{syncStatus.unpushedOps}</span></div>
|
||||||
{#if syncStatus.lastSyncAt}
|
{#if syncStatus.lastSyncAt}
|
||||||
<div class="sync-row"><span class="sync-label">{t('sync.lastSync')}</span><span class="sync-value">{syncStatus.lastSyncAt}</span></div>
|
<div class="sync-row"><span class="sync-label">Последняя синх.</span><span class="sync-value">{syncStatus.lastSyncAt}</span></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if syncStatus?.configured}
|
{#if syncStatus?.configured}
|
||||||
<div class="sync-connected-actions">
|
<div class="sync-connected-actions">
|
||||||
<button class="btn" on:click={runSyncNow} disabled={syncLoading}>{t('sync.syncNow')}</button>
|
<button class="btn" on:click={runSyncNow} disabled={syncLoading}>Синхронизировать</button>
|
||||||
<button class="btn btn-danger" on:click={disconnectSync} disabled={syncLoading}>{t('sync.disconnect')}</button>
|
<button class="btn btn-danger" on:click={disconnectSync} disabled={syncLoading}>Отключиться</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{t('sync.serverUrl')}</label>
|
<label>URL сервера</label>
|
||||||
<input type="text" placeholder={t('sync.serverUrlPlaceholder')} bind:value={syncServerUrl} />
|
<input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{t('sync.username')}</label>
|
<label>Логин</label>
|
||||||
<input type="text" placeholder={t('sync.usernamePlaceholder')} bind:value={syncUsername} />
|
<input type="text" placeholder="username" bind:value={syncUsername} />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{t('sync.password')}</label>
|
<label>Пароль</label>
|
||||||
<input type="password" placeholder={t('sync.passwordPlaceholder')} bind:value={syncPassword} />
|
<input type="password" placeholder="password" bind:value={syncPassword} />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions" style="margin-top:12px">
|
<div class="modal-actions" style="margin-top:12px">
|
||||||
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>{t('sync.test')}</button>
|
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>Проверить</button>
|
||||||
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>{t('sync.connect')}</button>
|
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #2a2a3c">
|
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #2a2a3c">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{t('sync.autoSync')}</label>
|
<label>Автосинхронизация (мин, 0 = отключено)</label>
|
||||||
<input type="number" placeholder="0" bind:value={syncInterval} min="0" />
|
<input type="number" placeholder="0" bind:value={syncInterval} min="0" />
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" on:click={saveSyncInterval} disabled={syncLoading}>{t('sync.saveInterval')}</button>
|
<button class="btn" on:click={saveSyncInterval} disabled={syncLoading}>Сохранить интервал</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if syncResult}
|
{#if syncResult}
|
||||||
|
|
@ -1627,7 +1619,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="modal-actions" style="margin-top:12px">
|
<div class="modal-actions" style="margin-top:12px">
|
||||||
<button class="btn" on:click={closeSettings}>{t('common.close')}</button>
|
<button class="btn" on:click={closeSettings}>Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
import FileIcon from './lib/FileIcon.svelte'
|
import FileIcon from './lib/FileIcon.svelte'
|
||||||
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
|
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
|
||||||
import { t } from './lib/i18n'
|
|
||||||
|
|
||||||
export let item
|
export let item
|
||||||
export let selected = false
|
export let selected = false
|
||||||
|
|
@ -27,7 +26,7 @@
|
||||||
if (clickTimer) {
|
if (clickTimer) {
|
||||||
clearTimeout(clickTimer)
|
clearTimeout(clickTimer)
|
||||||
clickTimer = null
|
clickTimer = null
|
||||||
// Double click → open
|
// Double click → открыть
|
||||||
if (isFolder) {
|
if (isFolder) {
|
||||||
dispatch('navigate', item.id)
|
dispatch('navigate', item.id)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -36,7 +35,7 @@
|
||||||
} else {
|
} else {
|
||||||
clickTimer = setTimeout(() => {
|
clickTimer = setTimeout(() => {
|
||||||
clickTimer = null
|
clickTimer = null
|
||||||
// Single click → select
|
// Single click → выделить
|
||||||
dispatch('selectOne', item.id)
|
dispatch('selectOne', item.id)
|
||||||
}, 250)
|
}, 250)
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +122,7 @@
|
||||||
on:dragstart={handleDragStart}
|
on:dragstart={handleDragStart}
|
||||||
on:dragover={handleDragOver}
|
on:dragover={handleDragOver}
|
||||||
on:drop={handleDrop}
|
on:drop={handleDrop}
|
||||||
aria-label={isFolder ? t('file.ariaFolder') + ' ' + item.name : t('file.ariaFile') + ' ' + item.name}>
|
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
|
||||||
<div class="file-row-icon">
|
<div class="file-row-icon">
|
||||||
<FileIcon {kind} size={22}/>
|
<FileIcon {kind} size={22}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -139,13 +138,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="file-row-actions">
|
<div class="file-row-actions">
|
||||||
{#if !isFolder}
|
{#if !isFolder}
|
||||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title={t('file.preview')} aria-label={t('file.preview')}>
|
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title={t('file.openExternal')} aria-label={t('file.openExternal')}>
|
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
|
@ -153,21 +152,21 @@
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title={t('file.openFolder')} aria-label={t('file.openFolder')}>
|
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title={t('file.more')} aria-label={t('file.more')} aria-expanded={menuOpen}>
|
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<circle cx="12" cy="5" r="2"/>
|
<circle cx="12" cy="5" r="2"/>
|
||||||
<circle cx="12" cy="12" r="2"/>
|
<circle cx="12" cy="12" r="2"/>
|
||||||
<circle cx="12" cy="19" r="2"/>
|
<circle cx="12" cy="19" r="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title={t('common.delete')} aria-label={t('common.delete')}>
|
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polyline points="3 6 5 6 21 6"/>
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
|
|
@ -182,39 +181,39 @@
|
||||||
<div class="menu" on:click|stopPropagation role="menu">
|
<div class="menu" on:click|stopPropagation role="menu">
|
||||||
<button class="menu-item" on:click={handleOpen} role="menuitem">
|
<button class="menu-item" on:click={handleOpen} role="menuitem">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
{t('common.open')}
|
Открыть
|
||||||
</button>
|
</button>
|
||||||
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
|
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
{t('file.openExternal')}
|
Открыть во внешней программе
|
||||||
</button>
|
</button>
|
||||||
{#if isFolder}
|
{#if isFolder}
|
||||||
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
|
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||||
{t('file.showInExplorer')}
|
Показать в проводнике
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="menu-sep"></div>
|
<div class="menu-sep"></div>
|
||||||
<button class="menu-item" on:click={handleRename} role="menuitem">
|
<button class="menu-item" on:click={handleRename} role="menuitem">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
{t('common.rename')}
|
Переименовать
|
||||||
</button>
|
</button>
|
||||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
|
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
{t('common.duplicate')}
|
Дублировать
|
||||||
</button>
|
</button>
|
||||||
<button class="menu-item" on:click={handleCut} role="menuitem">
|
<button class="menu-item" on:click={handleCut} role="menuitem">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
|
||||||
{t('common.cut')}
|
Вырезать
|
||||||
</button>
|
</button>
|
||||||
<button class="menu-item" on:click={handleCopy} role="menuitem">
|
<button class="menu-item" on:click={handleCopy} role="menuitem">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
{t('common.copy')}
|
Копировать
|
||||||
</button>
|
</button>
|
||||||
<div class="menu-sep"></div>
|
<div class="menu-sep"></div>
|
||||||
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
|
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
{t('common.delete')}
|
Удалить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Wails v2 API wrapper — uses generated bindings from wailsjs/go/main/App.js
|
||||||
|
import * as App from '../wailsjs/go/main/App.js'
|
||||||
|
|
||||||
|
// Re-export all methods
|
||||||
|
export const listSections = () => App.ListSections()
|
||||||
|
export const listTodayView = () => App.ListTodayView()
|
||||||
|
export const listNodesBySection = (section) => App.ListNodesBySection(section)
|
||||||
|
export const listChildren = (parentID) => App.ListChildren(parentID)
|
||||||
|
export const getNodeDetail = (id) => App.GetNodeDetail(id)
|
||||||
|
export const createNode = (parentID, type, title, section) => App.CreateNode(parentID, type, title, section)
|
||||||
|
export const deleteNode = (id) => App.DeleteNode(id)
|
||||||
|
|
||||||
|
export const listNotes = (nodeID) => App.ListNotes(nodeID)
|
||||||
|
export const createNote = (parentID, title) => App.CreateNote(parentID, title)
|
||||||
|
export const readNote = (noteID) => App.ReadNote(noteID)
|
||||||
|
export const saveNote = (noteID, content) => App.SaveNote(noteID, content)
|
||||||
|
|
||||||
|
export const listFiles = (nodeID) => App.ListFiles(nodeID)
|
||||||
|
export const listItems = (nodeID) => App.ListItems(nodeID)
|
||||||
|
export const addPathCopy = (nodeID, sourcePath) => App.AddPathCopy(nodeID, sourcePath)
|
||||||
|
export const addPathLink = (nodeID, sourcePath) => App.AddPathLink(nodeID, sourcePath)
|
||||||
|
export const deleteFileOrFolder = (nodeID) => App.DeleteFileOrFolder(nodeID)
|
||||||
|
export const previewImport = (sourcePath) => App.PreviewImport(sourcePath)
|
||||||
|
export const pickFile = () => App.PickFile()
|
||||||
|
export const pickFiles = () => App.PickFiles()
|
||||||
|
export const pickDirectory = () => App.PickDirectory()
|
||||||
|
export const openFile = (id) => App.OpenFile(id)
|
||||||
|
export const readFileText = (id) => App.ReadFileText(id)
|
||||||
|
export const getFileBase64 = (id) => App.GetFileBase64(id)
|
||||||
|
export const createEmptyFile = (parentID, filename) => App.CreateEmptyFile(parentID, filename)
|
||||||
|
export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
|
||||||
|
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
|
||||||
|
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
|
||||||
|
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
|
||||||
|
export const validateName = (name) => App.ValidateName(name)
|
||||||
|
|
||||||
|
export const listActions = (nodeID) => App.ListActions(nodeID)
|
||||||
|
export const runAction = (id) => App.RunAction(id)
|
||||||
|
|
||||||
|
export const listWorklog = (nodeID) => App.ListWorklog(nodeID)
|
||||||
|
export const createWorklog = (nodeID, summary, minutes) => App.CreateWorklog(nodeID, summary, minutes)
|
||||||
|
|
||||||
|
export const search = (query) => App.Search(query)
|
||||||
|
|
||||||
|
export const verstakVersion = () => App.VerstakVersion()
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte'
|
export let title = 'Подтверждение'
|
||||||
import { t } from './i18n'
|
|
||||||
|
|
||||||
export let title = t('common.confirm')
|
|
||||||
export let message = ''
|
export let message = ''
|
||||||
export let confirmText = t('common.delete')
|
export let confirmText = 'Удалить'
|
||||||
export let cancelText = t('common.cancel')
|
export let cancelText = 'Отмена'
|
||||||
export let danger = false
|
export let danger = false
|
||||||
|
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
|
export let isFolder = false
|
||||||
|
export let fileId = ''
|
||||||
|
export let nodeId = ''
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
if (isFolder) {
|
||||||
|
dispatch('openFolder', nodeId)
|
||||||
|
} else {
|
||||||
|
dispatch('open', fileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
dispatch('delete', nodeId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="file-actions">
|
||||||
|
<button class="action-btn" on:click={handleOpen} title={isFolder ? 'Open folder' : 'Open file'} aria-label={isFolder ? 'Open folder' : 'Open file'}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
{#if isFolder}
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||||
|
{:else}
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn-danger" on:click={handleDelete} title="Delete" aria-label="Delete">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.file-row:hover) .file-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: #2a2a3c;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger:hover {
|
||||||
|
background: #3a2222;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:focus-visible {
|
||||||
|
outline: 2px solid #5588ff;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
|
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
|
||||||
import FileIcon from './FileIcon.svelte'
|
import FileIcon from './FileIcon.svelte'
|
||||||
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
|
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
|
||||||
import { t } from './i18n'
|
|
||||||
|
|
||||||
export let item
|
export let item
|
||||||
export let content = '' // base64 data URI or text content
|
export let content = '' // base64 data URI or text content
|
||||||
|
|
@ -44,7 +43,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
||||||
<div class="preview-actions">
|
<div class="preview-actions">
|
||||||
<button class="action-btn" on:click={handleOpenExternal} title={t('file.openExternal')} aria-label={t('file.openExternal')}>
|
<button class="action-btn" on:click={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
|
@ -61,11 +60,11 @@
|
||||||
</header>
|
</header>
|
||||||
<div class="preview-body">
|
<div class="preview-body">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="preview-status"><p>{t('common.loading')}</p></div>
|
<div class="preview-status"><p>Загрузка...</p></div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="preview-status">
|
<div class="preview-status">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if showImage && content}
|
{:else if showImage && content}
|
||||||
<div class="preview-image-container">
|
<div class="preview-image-container">
|
||||||
|
|
@ -80,14 +79,14 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="preview-status">
|
<div class="preview-status">
|
||||||
<p>{t('file.pdfUnavailable')}</p>
|
<p>Предпросмотр PDF недоступен.</p>
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="preview-status">
|
<div class="preview-status">
|
||||||
<p>{t('file.previewUnavailable')}</p>
|
<p>Предпросмотр недоступен для этого типа файлов.</p>
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { t } from './i18n'
|
|
||||||
|
|
||||||
export function formatFileSize(bytes) {
|
export function formatFileSize(bytes) {
|
||||||
if (bytes == null || bytes < 0) return '—'
|
if (bytes == null || bytes < 0) return '—'
|
||||||
if (bytes === 0) return '0 B'
|
if (bytes === 0) return '0 B'
|
||||||
|
|
@ -10,51 +8,51 @@ export function formatFileSize(bytes) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mimeLabels = {
|
const mimeLabels = {
|
||||||
'image/jpeg': t('mime.jpeg'),
|
'image/jpeg': 'Изображение JPEG',
|
||||||
'image/png': t('mime.png'),
|
'image/png': 'Изображение PNG',
|
||||||
'image/gif': t('mime.gif'),
|
'image/gif': 'Изображение GIF',
|
||||||
'image/webp': t('mime.webp'),
|
'image/webp': 'Изображение WebP',
|
||||||
'image/svg+xml': t('mime.svg'),
|
'image/svg+xml': 'Изображение SVG',
|
||||||
'image/bmp': t('mime.bmp'),
|
'image/bmp': 'Изображение BMP',
|
||||||
'image/tiff': t('mime.tiff'),
|
'image/tiff': 'Изображение TIFF',
|
||||||
'image/avif': t('mime.avif'),
|
'image/avif': 'Изображение AVIF',
|
||||||
'application/pdf': t('mime.pdf'),
|
'application/pdf': 'PDF документ',
|
||||||
'application/msword': t('mime.word'),
|
'application/msword': 'Документ Word',
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': t('mime.word'),
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
|
||||||
'application/vnd.ms-excel': t('mime.excel'),
|
'application/vnd.ms-excel': 'Таблица Excel',
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': t('mime.excel'),
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
|
||||||
'application/vnd.ms-powerpoint': t('mime.ppt'),
|
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': t('mime.ppt'),
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
|
||||||
'application/zip': t('mime.zip'),
|
'application/zip': 'ZIP архив',
|
||||||
'application/gzip': t('mime.gzip'),
|
'application/gzip': 'GZIP архив',
|
||||||
'application/x-tar': t('mime.tar'),
|
'application/x-tar': 'TAR архив',
|
||||||
'application/x-7z-compressed': t('mime.sevenz'),
|
'application/x-7z-compressed': '7z архив',
|
||||||
'application/x-rar-compressed': t('mime.rar'),
|
'application/x-rar-compressed': 'RAR архив',
|
||||||
'text/plain': t('mime.text'),
|
'text/plain': 'Текстовый файл',
|
||||||
'text/html': t('mime.html'),
|
'text/html': 'HTML файл',
|
||||||
'text/css': t('mime.css'),
|
'text/css': 'CSS файл',
|
||||||
'text/javascript': t('mime.js'),
|
'text/javascript': 'JavaScript файл',
|
||||||
'application/json': t('mime.json'),
|
'application/json': 'JSON файл',
|
||||||
'application/xml': t('mime.xml'),
|
'application/xml': 'XML файл',
|
||||||
'application/x-yaml': t('mime.yaml'),
|
'application/x-yaml': 'YAML файл',
|
||||||
'application/octet-stream': t('mime.binary'),
|
'application/octet-stream': 'Бинарный файл',
|
||||||
'application/x-msdos-program': t('mime.executable'),
|
'application/x-msdos-program': 'Исполняемый файл',
|
||||||
'inode/directory': t('mime.folder'),
|
'inode/directory': 'Папка',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMimeType(mime) {
|
export function formatMimeType(mime) {
|
||||||
if (!mime) return t('mime.unknown')
|
if (!mime) return 'Неизвестно'
|
||||||
return mimeLabels[mime] || mime
|
return mimeLabels[mime] || mime
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFileType(item) {
|
export function formatFileType(item) {
|
||||||
if (item.type === 'folder') return t('mime.folder')
|
if (item.type === 'folder') return 'Папка'
|
||||||
const mime = (item.mime || '').toLowerCase()
|
const mime = (item.mime || '').toLowerCase()
|
||||||
if (mimeLabels[mime]) return mimeLabels[mime]
|
if (mimeLabels[mime]) return mimeLabels[mime]
|
||||||
const name = (item.name || '').toLowerCase()
|
const name = (item.name || '').toLowerCase()
|
||||||
const ext = name.split('.').pop()
|
const ext = name.split('.').pop()
|
||||||
if (ext) return ext.toUpperCase()
|
if (ext) return ext.toUpperCase()
|
||||||
return t('mime.file')
|
return 'Файл'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFileKind(item) {
|
export function getFileKind(item) {
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
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': 'Сначала выберите дело',
|
|
||||||
'common.open': 'Открыть',
|
|
||||||
'delete.files': 'файлов ({count})',
|
|
||||||
'file.namePrompt': 'Введите имя файла:',
|
|
||||||
'file.pdfUnavailable': 'Предпросмотр PDF недоступен.',
|
|
||||||
'file.previewUnavailable': 'Предпросмотр недоступен для этого типа файлов.',
|
|
||||||
'case.new': 'Новое дело',
|
|
||||||
'case.namePlaceholder': 'Название дела',
|
|
||||||
|
|
||||||
'error.generic': 'Произошла ошибка',
|
|
||||||
'error.invalidCredentials': 'Неверный логин или пароль',
|
|
||||||
'error.accountBlocked': 'Аккаунт заблокирован',
|
|
||||||
'error.emailNotConfirmed': 'Email не подтверждён',
|
|
||||||
'error.tokenInvalid': 'Неверный или просроченный токен',
|
|
||||||
'error.tokenExpired': 'Срок действия токена истёк',
|
|
||||||
}
|
|
||||||
|
|
@ -15,29 +15,29 @@ import (
|
||||||
|
|
||||||
// Kind constants.
|
// Kind constants.
|
||||||
const (
|
const (
|
||||||
KindOpenURL = "open_url"
|
KindOpenURL = "open_url"
|
||||||
KindOpenFile = "open_file"
|
KindOpenFile = "open_file"
|
||||||
KindOpenFolder = "open_folder"
|
KindOpenFolder = "open_folder"
|
||||||
KindRunCommand = "run_command"
|
KindRunCommand = "run_command"
|
||||||
KindRunScript = "run_script"
|
KindRunScript = "run_script"
|
||||||
KindOpenTerminal = "open_terminal"
|
KindOpenTerminal = "open_terminal"
|
||||||
KindLaunchApp = "launch_app"
|
KindLaunchApp = "launch_app"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Record represents an action attached to a node.
|
// Record represents an action attached to a node.
|
||||||
type Record struct {
|
type Record struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Command string `json:"command,omitempty"`
|
Command string `json:"command,omitempty"`
|
||||||
Args []string `json:"args,omitempty"`
|
Args []string `json:"args,omitempty"`
|
||||||
WorkingDir string `json:"working_dir,omitempty"`
|
WorkingDir string `json:"working_dir,omitempty"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
ConfirmRequired bool `json:"confirm_required"`
|
ConfirmRequired bool `json:"confirm_required"`
|
||||||
CaptureOutput bool `json:"capture_output"`
|
CaptureOutput bool `json:"capture_output"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunResult is returned after executing an action.
|
// RunResult is returned after executing an action.
|
||||||
|
|
|
||||||
|
|
@ -130,10 +130,10 @@ func TestGetNotFound(t *testing.T) {
|
||||||
|
|
||||||
func TestKindLabel(t *testing.T) {
|
func TestKindLabel(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
KindOpenURL: "Открыть URL",
|
KindOpenURL: "Открыть URL",
|
||||||
KindRunCommand: "Запустить команду",
|
KindRunCommand: "Запустить команду",
|
||||||
KindOpenFolder: "Открыть папку",
|
KindOpenFolder: "Открыть папку",
|
||||||
"unknown": "unknown",
|
"unknown": "unknown",
|
||||||
}
|
}
|
||||||
for kind, want := range cases {
|
for kind, want := range cases {
|
||||||
got := KindLabel(kind)
|
got := KindLabel(kind)
|
||||||
|
|
|
||||||
|
|
@ -10,30 +10,30 @@ import (
|
||||||
|
|
||||||
// Event types.
|
// Event types.
|
||||||
const (
|
const (
|
||||||
TypeNoteCreated = "note_created"
|
TypeNoteCreated = "note_created"
|
||||||
TypeNoteUpdated = "note_updated"
|
TypeNoteUpdated = "note_updated"
|
||||||
TypeFileAdded = "file_added"
|
TypeFileAdded = "file_added"
|
||||||
TypeFileDeleted = "file_deleted"
|
TypeFileDeleted = "file_deleted"
|
||||||
TypeFileRenamed = "file_renamed"
|
TypeFileRenamed = "file_renamed"
|
||||||
TypeFileCopied = "file_copied"
|
TypeFileCopied = "file_copied"
|
||||||
TypeFileMoved = "file_moved"
|
TypeFileMoved = "file_moved"
|
||||||
TypeFolderAdded = "folder_added"
|
TypeFolderAdded = "folder_added"
|
||||||
TypeFolderDeleted = "folder_deleted"
|
TypeFolderDeleted = "folder_deleted"
|
||||||
TypeFolderRenamed = "folder_renamed"
|
TypeFolderRenamed = "folder_renamed"
|
||||||
TypeNodeCreated = "node_created"
|
TypeNodeCreated = "node_created"
|
||||||
TypeNodeUpdated = "node_updated"
|
TypeNodeUpdated = "node_updated"
|
||||||
TypeActionCreated = "action_created"
|
TypeActionCreated = "action_created"
|
||||||
TypeActionDone = "action_done"
|
TypeActionDone = "action_done"
|
||||||
TypeWorklogAdded = "worklog_added"
|
TypeWorklogAdded = "worklog_added"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Target types.
|
// Target types.
|
||||||
const (
|
const (
|
||||||
TargetNote = "note"
|
TargetNote = "note"
|
||||||
TargetFile = "file"
|
TargetFile = "file"
|
||||||
TargetFolder = "folder"
|
TargetFolder = "folder"
|
||||||
TargetAction = "action"
|
TargetAction = "action"
|
||||||
TargetNode = "node"
|
TargetNode = "node"
|
||||||
TargetWorklog = "worklog"
|
TargetWorklog = "worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ type Record struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Path string `json:"path"` // relative to vault root
|
Path string `json:"path"` // relative to vault root
|
||||||
StorageMode string `json:"storage_mode"` // "vault" | "external"
|
StorageMode string `json:"storage_mode"` // "vault" | "external"
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
SHA256 string `json:"sha256,omitempty"`
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -307,11 +307,11 @@ func TestPreviewImportDir(t *testing.T) {
|
||||||
|
|
||||||
func TestGuessMIME(t *testing.T) {
|
func TestGuessMIME(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"a.md": "text/plain",
|
"a.md": "text/plain",
|
||||||
"a.png": "image/png",
|
"a.png": "image/png",
|
||||||
"a.pdf": "application/pdf",
|
"a.pdf": "application/pdf",
|
||||||
"a.docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
"a.docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
"a.go": "text/plain",
|
"a.go": "text/plain",
|
||||||
"a.unknown": "application/octet-stream",
|
"a.unknown": "application/octet-stream",
|
||||||
}
|
}
|
||||||
for name, want := range cases {
|
for name, want := range cases {
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,19 @@ import (
|
||||||
// Node is the central entity of Verstak — a tree item that can be
|
// Node is the central entity of Verstak — a tree item that can be
|
||||||
// a case, folder, note, document, etc.
|
// a case, folder, note, document, etc.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ParentID *string `json:"parent_id,omitempty"`
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Path *string `json:"path,omitempty"`
|
Path *string `json:"path,omitempty"`
|
||||||
Section string `json:"section,omitempty"`
|
Section string `json:"section,omitempty"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
Revision int `json:"revision"`
|
Revision int `json:"revision"`
|
||||||
DeviceID *string `json:"device_id,omitempty"`
|
DeviceID *string `json:"device_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDeleted reports whether the node has been soft-deleted.
|
// IsDeleted reports whether the node has been soft-deleted.
|
||||||
|
|
@ -40,6 +40,6 @@ type Meta struct {
|
||||||
|
|
||||||
// NodeWithMeta bundles a node and its metadata entries.
|
// NodeWithMeta bundles a node and its metadata entries.
|
||||||
type NodeWithMeta struct {
|
type NodeWithMeta struct {
|
||||||
Node Node `json:"node"`
|
Node Node `json:"node"`
|
||||||
Meta []Meta `json:"meta"`
|
Meta []Meta `json:"meta"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,8 @@ func (m *Manager) Discover() {
|
||||||
meta.Name = e.Name()
|
meta.Name = e.Name()
|
||||||
}
|
}
|
||||||
m.plugins = append(m.plugins, Plugin{
|
m.plugins = append(m.plugins, Plugin{
|
||||||
Meta: meta,
|
Meta: meta,
|
||||||
Dir: filepath.Join(pluginsDir, e.Name()),
|
Dir: filepath.Join(pluginsDir, e.Name()),
|
||||||
Active: true,
|
Active: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -116,13 +116,13 @@ func (m *Manager) Templates() []TemplateDefinition {
|
||||||
|
|
||||||
// TemplateDefinition describes a predefined tree of nodes.
|
// TemplateDefinition describes a predefined tree of nodes.
|
||||||
type TemplateDefinition struct {
|
type TemplateDefinition struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
Plugin string // source plugin name
|
Plugin string // source plugin name
|
||||||
RootType string `json:"root_type"`
|
RootType string `json:"root_type"`
|
||||||
Tree []TreeNode `json:"tree"`
|
Tree []TreeNode `json:"tree"`
|
||||||
Meta []NodeMeta `json:"meta,omitempty"`
|
Meta []NodeMeta `json:"meta,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TreeNode is a single item in a template tree.
|
// TreeNode is a single item in a template tree.
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import (
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/nodes"
|
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
|
"verstak/internal/core/nodes"
|
||||||
"verstak/internal/core/search"
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
"verstak/internal/core/vault"
|
"verstak/internal/core/vault"
|
||||||
|
|
|
||||||
|
|
@ -57,12 +57,12 @@ CREATE TABLE IF NOT EXISTS _schema_ver (
|
||||||
`
|
`
|
||||||
|
|
||||||
var migrationFiles = map[int]string{
|
var migrationFiles = map[int]string{
|
||||||
1: migration001,
|
1: migration001,
|
||||||
2: migration002,
|
2: migration002,
|
||||||
3: migration003,
|
3: migration003,
|
||||||
4: migration004,
|
4: migration004,
|
||||||
5: migration005,
|
5: migration005,
|
||||||
6: migration006,
|
6: migration006,
|
||||||
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
||||||
8: migration008,
|
8: migration008,
|
||||||
9: migration009,
|
9: migration009,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
DeviceID: deviceID,
|
DeviceID: deviceID,
|
||||||
VaultRoot: vaultRoot,
|
VaultRoot: vaultRoot,
|
||||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,9 +145,9 @@ func (c *Client) TestAuth(serverURL, username, password string) error {
|
||||||
|
|
||||||
// PushRequest is the payload for POST /sync/push.
|
// PushRequest is the payload for POST /sync/push.
|
||||||
type PushRequest struct {
|
type PushRequest struct {
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
Ops []PushOp `json:"ops"`
|
Ops []PushOp `json:"ops"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushOp is a single operation in a push request.
|
// PushOp is a single operation in a push request.
|
||||||
|
|
@ -164,8 +164,8 @@ type PushOp struct {
|
||||||
|
|
||||||
// PushResponse is the response from POST /sync/push.
|
// PushResponse is the response from POST /sync/push.
|
||||||
type PushResponse struct {
|
type PushResponse struct {
|
||||||
Accepted []string `json:"accepted"`
|
Accepted []string `json:"accepted"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Conflicts []map[string]interface{} `json:"conflicts"`
|
Conflicts []map[string]interface{} `json:"conflicts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -30,18 +30,18 @@ const (
|
||||||
|
|
||||||
// Op represents a sync operation.
|
// Op represents a sync operation.
|
||||||
type Op struct {
|
type Op struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OpID string `json:"op_id"`
|
OpID string `json:"op_id"`
|
||||||
ServerSequence int `json:"server_sequence,omitempty"`
|
ServerSequence int `json:"server_sequence,omitempty"`
|
||||||
DeviceID string `json:"device_id,omitempty"`
|
DeviceID string `json:"device_id,omitempty"`
|
||||||
EntityType string `json:"entity_type"`
|
EntityType string `json:"entity_type"`
|
||||||
EntityID string `json:"entity_id"`
|
EntityID string `json:"entity_id"`
|
||||||
OpType string `json:"op_type"`
|
OpType string `json:"op_type"`
|
||||||
PayloadJSON string `json:"payload_json"`
|
PayloadJSON string `json:"payload_json"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
PushedAt *string `json:"pushed_at,omitempty"`
|
PushedAt *string `json:"pushed_at,omitempty"`
|
||||||
ClientSequence int `json:"client_sequence,omitempty"`
|
ClientSequence int `json:"client_sequence,omitempty"`
|
||||||
LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"`
|
LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service records and manages sync operations.
|
// Service records and manages sync operations.
|
||||||
|
|
|
||||||
|
|
@ -132,16 +132,16 @@ func TestE2ESync(t *testing.T) {
|
||||||
"device_id": deviceIDA,
|
"device_id": deviceIDA,
|
||||||
"ops": []map[string]interface{}{
|
"ops": []map[string]interface{}{
|
||||||
{
|
{
|
||||||
"op_id": "op-node-create-001",
|
"op_id": "op-node-create-001",
|
||||||
"entity_type": "node",
|
"entity_type": "node",
|
||||||
"entity_id": nodeID,
|
"entity_id": nodeID,
|
||||||
"op_type": "create",
|
"op_type": "create",
|
||||||
"payload_json": fmt.Sprintf(
|
"payload_json": fmt.Sprintf(
|
||||||
`{"id":"%s","parent_id":"","type":"case","title":"Test Project","slug":"test-project","section":"projects","created_at":"%s","updated_at":"%s"}`,
|
`{"id":"%s","parent_id":"","type":"case","title":"Test Project","slug":"test-project","section":"projects","created_at":"%s","updated_at":"%s"}`,
|
||||||
nodeID, now, now),
|
nodeID, now, now),
|
||||||
"client_sequence": 1,
|
"client_sequence": 1,
|
||||||
"last_seen_server_seq": 0,
|
"last_seen_server_seq": 0,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"idempotency_key": "e2e-test-push-1",
|
"idempotency_key": "e2e-test-push-1",
|
||||||
|
|
@ -161,7 +161,7 @@ func TestE2ESync(t *testing.T) {
|
||||||
t.Fatalf("push A status %d: %s", resp.StatusCode, string(body))
|
t.Fatalf("push A status %d: %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
var pushRespA struct {
|
var pushRespA struct {
|
||||||
Accepted []string `json:"accepted"`
|
Accepted []string `json:"accepted"`
|
||||||
Conflicts []interface{} `json:"conflicts"`
|
Conflicts []interface{} `json:"conflicts"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(resp.Body).Decode(&pushRespA)
|
json.NewDecoder(resp.Body).Decode(&pushRespA)
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@ package gui
|
||||||
// готова к упаковке (нет external JS/CSS, fetch к /api/* через origin).
|
// готова к упаковке (нет external JS/CSS, fetch к /api/* через origin).
|
||||||
//
|
//
|
||||||
// navigation state:
|
// navigation state:
|
||||||
//
|
// sel = { kind:'section', section:'today'|'inbox'|'clients'|'projects'|'recipes'|'documents'|'archive' }
|
||||||
// sel = { kind:'section', section:'today'|'inbox'|'clients'|'projects'|'recipes'|'documents'|'archive' }
|
// or { kind:'node', nodeId:'<uuid>' }
|
||||||
// or { kind:'node', nodeId:'<uuid>' }
|
// tab = 'ov'|'notes'|'files'|'actions'|'worklog'|'activity'
|
||||||
// tab = 'ov'|'notes'|'files'|'actions'|'worklog'|'activity'
|
|
||||||
const indexHTML = `<!DOCTYPE html>
|
const indexHTML = `<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ import (
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/nodes"
|
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
|
"verstak/internal/core/nodes"
|
||||||
"verstak/internal/core/plugins"
|
"verstak/internal/core/plugins"
|
||||||
"verstak/internal/core/search"
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
|
|
@ -280,9 +280,7 @@ func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
jsonOK(w, n)
|
jsonOK(w, n)
|
||||||
case "PUT":
|
case "PUT":
|
||||||
var req struct {
|
var req struct{ Content string `json:"content"` }
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
if err := s.notes.Save(path, req.Content); err != nil {
|
if err := s.notes.Save(path, req.Content); err != nil {
|
||||||
jsonErr(w, 500, err.Error())
|
jsonErr(w, 500, err.Error())
|
||||||
|
|
@ -367,15 +365,15 @@ func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonOK(w, list)
|
jsonOK(w, list)
|
||||||
case "POST":
|
case "POST":
|
||||||
var req struct {
|
var req struct {
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
WorkingDir string `json:"working_dir"`
|
WorkingDir string `json:"working_dir"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
Confirm bool `json:"confirm"`
|
Confirm bool `json:"confirm"`
|
||||||
Capture bool `json:"capture"`
|
Capture bool `json:"capture"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
rec, err := s.actions.Create(req.NodeID, req.Kind, req.Title, req.Command, req.WorkingDir, req.URL, req.Args, req.Confirm, req.Capture)
|
rec, err := s.actions.Create(req.NodeID, req.Kind, req.Title, req.Command, req.WorkingDir, req.URL, req.Args, req.Confirm, req.Capture)
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,383 +0,0 @@
|
||||||
{
|
|
||||||
"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": "Срок действия токена истёк"
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
#!/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