Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e47fcb17 | ||
|
|
0ef54c31f8 | ||
|
|
b3662d4876 | ||
|
|
f8dc436709 | ||
|
|
241a9d8c06 | ||
|
|
5db3da3618 | ||
|
|
e828ebd44e | ||
|
|
84c0bcbcab | ||
|
|
a1a50863c5 | ||
|
|
1abe8c4fa0 | ||
|
|
5b2cec5bcc | ||
|
|
1a20edac44 | ||
|
|
ad684eb118 | ||
|
|
10c6d06e38 | ||
|
|
ec928e3be6 | ||
|
|
c5e0060fee | ||
|
|
834b5ef0d4 | ||
|
|
4145b4d74a | ||
|
|
edc708a106 | ||
|
|
ee708d30bb | ||
|
|
305158ecc6 | ||
|
|
996322f3a9 | ||
|
|
a098cf721c | ||
|
|
3672e3133b | ||
|
|
5a1c4c6d7f | ||
|
|
08c9d5dbea | ||
|
|
c74fa3ad43 | ||
|
|
69891e395c | ||
|
|
a4ae22c445 | ||
|
|
2487d3bbaa | ||
|
|
645d8878cc |
+7
-2
@@ -22,8 +22,13 @@ go.work
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/bindings/
|
frontend/bindings/
|
||||||
verstak-gui
|
/verstak-gui
|
||||||
verstak-cli
|
/verstak-cli
|
||||||
|
/verstak-server
|
||||||
|
|
||||||
|
# Vault data
|
||||||
|
.verstak/
|
||||||
|
spaces/
|
||||||
|
|
||||||
# VS Code
|
# VS Code
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd frontend && npm run build && cd ..
|
||||||
|
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
|
||||||
|
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
||||||
+679
-20
@@ -5,18 +5,28 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/activity"
|
||||||
|
"verstak/internal/core/config"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
|
"verstak/internal/core/plugins"
|
||||||
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
|
syncsvc "verstak/internal/core/sync"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// App is the Wails v2 application adapter. It wraps core services.
|
// App is the Wails v2 application adapter. It wraps core services.
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -24,14 +34,23 @@ type App struct {
|
|||||||
nodes *nodes.Repository
|
nodes *nodes.Repository
|
||||||
files *files.Service
|
files *files.Service
|
||||||
notes *notes.Service
|
notes *notes.Service
|
||||||
|
activity *activity.Service
|
||||||
actions *actions.Service
|
actions *actions.Service
|
||||||
worklog *worklog.Service
|
worklog *worklog.Service
|
||||||
|
search *search.Service
|
||||||
|
plugins *plugins.Manager
|
||||||
|
sync *syncsvc.Service
|
||||||
vault string
|
vault string
|
||||||
}
|
}
|
||||||
|
|
||||||
// startup is called when the app starts. Store context.
|
// startup is called when the app starts. Store context and wire drag-and-drop.
|
||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
wailsruntime.OnFileDrop(ctx, func(x, y int, paths []string) {
|
||||||
|
if len(paths) > 0 {
|
||||||
|
wailsruntime.EventsEmit(ctx, "files-dropped", paths)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -72,6 +91,16 @@ type FileDTO struct {
|
|||||||
Missing bool `json:"missing"`
|
Missing bool `json:"missing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileTreeItemDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"` // "folder" | "file"
|
||||||
|
FileID string `json:"fileId,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
Mime string `json:"mime,omitempty"`
|
||||||
|
HasKids bool `json:"hasKids"`
|
||||||
|
}
|
||||||
|
|
||||||
type ActionDTO struct {
|
type ActionDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
NodeID string `json:"nodeId"`
|
NodeID string `json:"nodeId"`
|
||||||
@@ -95,6 +124,47 @@ type SearchResultDTO struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EventDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
NodeID string `json:"nodeId"`
|
||||||
|
EventType string `json:"eventType"`
|
||||||
|
TargetType string `json:"targetType"`
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
TargetPath string `json:"targetPath"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
DetailsJSON string `json:"detailsJson"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaseActivityDTO struct {
|
||||||
|
Node NodeDTO `json:"node"`
|
||||||
|
Events []EventDTO `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SummaryDTO struct {
|
||||||
|
ChangedCases int `json:"changedCases"`
|
||||||
|
Notes int `json:"notes"`
|
||||||
|
Files int `json:"files"`
|
||||||
|
Actions int `json:"actions"`
|
||||||
|
TimeEntries int `json:"timeEntries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayGroupDTO struct {
|
||||||
|
NodeID string `json:"nodeId"`
|
||||||
|
NodeTitle string `json:"nodeTitle"`
|
||||||
|
NodeKind string `json:"nodeKind"`
|
||||||
|
Section string `json:"section"`
|
||||||
|
LastActivityAt string `json:"lastActivityAt"`
|
||||||
|
Events []EventDTO `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayDashboardDTO struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Summary SummaryDTO `json:"summary"`
|
||||||
|
Groups []TodayGroupDTO `json:"groups"`
|
||||||
|
Events []EventDTO `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Sections
|
// Sections
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -103,6 +173,7 @@ func (a *App) ListSections() []SectionDTO {
|
|||||||
return []SectionDTO{
|
return []SectionDTO{
|
||||||
{ID: "today", Label: "Сегодня"},
|
{ID: "today", Label: "Сегодня"},
|
||||||
{ID: "inbox", Label: "Неразобранное"},
|
{ID: "inbox", Label: "Неразобранное"},
|
||||||
|
{ID: "activity", Label: "Активность"},
|
||||||
{ID: "clients", Label: "Клиенты"},
|
{ID: "clients", Label: "Клиенты"},
|
||||||
{ID: "projects", Label: "Проекты"},
|
{ID: "projects", Label: "Проекты"},
|
||||||
{ID: "recipes", Label: "Рецепты"},
|
{ID: "recipes", Label: "Рецепты"},
|
||||||
@@ -123,6 +194,175 @@ func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
|
|||||||
return toNodeDTOs(list), nil
|
return toNodeDTOs(list), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListTodayView returns a dashboard of today's activity.
|
||||||
|
// For MVP this uses activity_events + root nodes changed today.
|
||||||
|
// Future: full Activity/Event Log system will be the single source of truth.
|
||||||
|
func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
|
||||||
|
// Collect events from activity_events, grouped by parent node.
|
||||||
|
aeByParent, err := a.activity.ListTodayEventsByParent()
|
||||||
|
if err != nil {
|
||||||
|
aeByParent = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root nodes that were created/updated today.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge activity_events.
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all today's root nodes are present (even without events).
|
||||||
|
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 toEventDTO(e activity.Event) EventDTO {
|
||||||
|
return EventDTO{
|
||||||
|
ID: e.ID,
|
||||||
|
NodeID: e.NodeID,
|
||||||
|
EventType: e.EventType,
|
||||||
|
TargetType: e.TargetType,
|
||||||
|
TargetID: e.TargetID,
|
||||||
|
TargetPath: e.TargetPath,
|
||||||
|
Title: e.Title,
|
||||||
|
DetailsJSON: e.DetailsJSON,
|
||||||
|
CreatedAt: e.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
|
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
|
||||||
list, err := a.nodes.ListChildren(parentID, false)
|
list, err := a.nodes.ListChildren(parentID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -141,10 +381,15 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
|
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)
|
n, err := a.nodes.Create(parentID, nodeType, title, section)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
|
||||||
|
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, map[string]string{"title": title})
|
||||||
dto := toNodeDTO(n)
|
dto := toNodeDTO(n)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
@@ -153,6 +398,66 @@ func (a *App) DeleteNode(id string) error {
|
|||||||
return a.nodes.SoftDelete(id)
|
return a.nodes.SoftDelete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Templates
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
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, fmt.Errorf("template %q not found", template)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Notes
|
// Notes
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -178,6 +483,8 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
||||||
|
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, map[string]string{"title": title})
|
||||||
dto := toNodeDTO(node)
|
dto := toNodeDTO(node)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
@@ -189,13 +496,26 @@ func (a *App) ReadNote(noteID string) (string, error) {
|
|||||||
|
|
||||||
// SaveNote saves note content.
|
// SaveNote saves note content.
|
||||||
func (a *App) SaveNote(noteID, content string) error {
|
func (a *App) SaveNote(noteID, content string) error {
|
||||||
return a.notes.Save(noteID, content)
|
if err := a.notes.Save(noteID, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Record note_updated event.
|
||||||
|
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]string{"title": n.Title})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Files
|
// Files
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
// ListFiles returns file records directly linked to a node (non-recursive).
|
||||||
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
||||||
records, err := a.files.ListByNode(nodeID)
|
records, err := a.files.ListByNode(nodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,22 +523,198 @@ func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
|||||||
}
|
}
|
||||||
result := make([]FileDTO, len(records))
|
result := make([]FileDTO, len(records))
|
||||||
for i := range records {
|
for i := range records {
|
||||||
isDir := records[i].MIME == "inode/directory"
|
rec := &records[i]
|
||||||
missing := false
|
|
||||||
result[i] = FileDTO{
|
result[i] = FileDTO{
|
||||||
ID: records[i].ID,
|
ID: rec.ID,
|
||||||
NodeID: records[i].NodeID,
|
NodeID: rec.NodeID,
|
||||||
Name: records[i].Filename,
|
Name: rec.Filename,
|
||||||
Path: records[i].Path,
|
Path: rec.Path,
|
||||||
Size: records[i].Size,
|
Size: rec.Size,
|
||||||
Mime: records[i].MIME,
|
Mime: rec.MIME,
|
||||||
IsDir: isDir,
|
IsDir: rec.MIME == "inode/directory",
|
||||||
Missing: missing,
|
Missing: rec.Missing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListItems returns children of a node for the file tree view.
|
||||||
|
// Folders can be expanded; files include their file record info.
|
||||||
|
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 {
|
||||||
|
// Check if this folder has children
|
||||||
|
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, map[string]string{"title": n.Title})
|
||||||
|
}
|
||||||
|
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, map[string]string{"title": n.Title})
|
||||||
|
}
|
||||||
|
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, map[string]string{"title": filename})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// Find parent for recording
|
||||||
|
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, map[string]string{"title": node.Title})
|
||||||
|
dto := toNodeDTO(node)
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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]string{"title": newTitle})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ValidateName(name string) error {
|
||||||
|
return files.ValidateName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||||
|
// Check for name conflict at destination
|
||||||
|
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 {
|
||||||
|
// Conflict: auto-rename
|
||||||
|
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]string{"title": node.Title})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
||||||
|
return a.files.PreviewImport(sourcePath)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Actions
|
// Actions
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -245,6 +741,26 @@ func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
|
|||||||
return result, nil
|
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, map[string]string{"title": rec.Title, "kind": rec.Kind})
|
||||||
|
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 {
|
func (a *App) RunAction(id string) error {
|
||||||
_, err := a.actions.Run(id)
|
_, err := a.actions.Run(id)
|
||||||
return err
|
return err
|
||||||
@@ -281,6 +797,7 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, map[string]string{"summary": summary})
|
||||||
mins := 0
|
mins := 0
|
||||||
if entry.Minutes != nil {
|
if entry.Minutes != nil {
|
||||||
mins = *entry.Minutes
|
mins = *entry.Minutes
|
||||||
@@ -296,14 +813,143 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Search (stubs — search service not wired yet)
|
// Search
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
||||||
if strings.TrimSpace(query) == "" {
|
if strings.TrimSpace(query) == "" {
|
||||||
return []SearchResultDTO{}, nil
|
return []SearchResultDTO{}, nil
|
||||||
}
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sync
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type SyncStatusDTO struct {
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
ServerURL string `json:"serverUrl"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
unpushed, _ := a.sync.GetUnpushedOps()
|
||||||
|
cfg, _ := config.Load(a.vault)
|
||||||
|
dto := &SyncStatusDTO{
|
||||||
|
Configured: serverURL != "" && apiKey != "",
|
||||||
|
ServerURL: serverURL,
|
||||||
|
UnpushedOps: len(unpushed),
|
||||||
|
LastSyncAt: lastSyncAt,
|
||||||
|
}
|
||||||
|
if cfg != nil {
|
||||||
|
dto.DeviceID = cfg.Sync.DeviceID
|
||||||
|
dto.SyncInterval = cfg.Sync.SyncInterval
|
||||||
|
}
|
||||||
|
return dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||||
|
// Register device on server with user credentials.
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = "unknown"
|
||||||
|
}
|
||||||
|
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||||
|
deviceID, apiKey, err := client.RegisterDeviceWithAuth(hostname, username, password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("register: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.sync.SetState(serverURL, apiKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Persist to vault config.
|
||||||
|
cfg, err := config.Load(a.vault)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Sync.ServerURL = serverURL
|
||||||
|
cfg.Sync.APIKey = apiKey
|
||||||
|
cfg.Sync.DeviceID = deviceID
|
||||||
|
return config.Save(a.vault, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
||||||
|
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||||
|
_, _, err := client.RegisterDeviceWithAuth("test-connection", username, password)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SyncSetInterval(minutes int) error {
|
||||||
|
cfg, err := config.Load(a.vault)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Sync.SyncInterval = minutes
|
||||||
|
return config.Save(a.vault, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||||
|
serverURL, apiKey, lastRev, _, err := a.sync.GetState()
|
||||||
|
if err != nil || serverURL == "" || apiKey == "" {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Push unpushed ops.
|
||||||
|
unpushed, err := a.sync.GetUnpushedOps()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get ops: %w", err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull remote ops.
|
||||||
|
pullResult, err := client.Pull(lastRev)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pull: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"pushed": len(pushResult.Accepted),
|
||||||
|
"pulled": len(pullResult.Ops),
|
||||||
|
"serverRevision": pullResult.ServerRevision,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -333,11 +979,27 @@ func (a *App) PickDirectory() (string, error) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
func (a *App) OpenFile(fileID string) error {
|
func (a *App) OpenFile(fileID string) error {
|
||||||
return fmt.Errorf("not implemented: %s", fileID)
|
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 {
|
func (a *App) OpenFolder(nodeID string) error {
|
||||||
cmd := exec.Command("xdg-open", a.vault)
|
n, err := a.nodes.GetActive(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get node: %w", 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()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +1039,4 @@ func toNodeDTOs(list []nodes.Node) []NodeDTO {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
|
||||||
_ = os.Getenv
|
|
||||||
_ = exec.Command
|
|
||||||
)
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.svelte-14ysusk.svelte-14ysusk,.svelte-14ysusk.svelte-14ysusk:before,.svelte-14ysusk.svelte-14ysusk:after{box-sizing:border-box;margin:0;padding:0}.app.svelte-14ysusk.svelte-14ysusk{display:flex;width:100vw;height:100vh;overflow:hidden;background:#13131f;color:#e4e4ef;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px}.sidebar.svelte-14ysusk.svelte-14ysusk{width:260px;min-width:200px;height:100vh;display:flex;flex-direction:column;background:#1a1a28;border-right:1px solid #2a2a3c;flex-shrink:0;overflow:hidden}.sidebar-top.svelte-14ysusk.svelte-14ysusk{padding:16px 20px;display:flex;align-items:center;gap:10px;border-bottom:1px solid #2a2a3c;flex-shrink:0}.logo.svelte-14ysusk.svelte-14ysusk{font-size:20px;line-height:1}.app-name.svelte-14ysusk.svelte-14ysusk{font-size:16px;font-weight:600;color:#e4e4ef}.sidebar-nav.svelte-14ysusk.svelte-14ysusk{flex:1;overflow-y:auto;padding:12px 0}.nav-group.svelte-14ysusk.svelte-14ysusk{margin-bottom:16px}.nav-label.svelte-14ysusk.svelte-14ysusk{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#666;padding:4px 20px;margin-bottom:4px}.nav-item.svelte-14ysusk.svelte-14ysusk{display:block;width:100%;padding:8px 20px;border:none;background:none;color:#ccc;font-size:13px;text-align:left;cursor:pointer;border-radius:0;font-family:inherit}.nav-item.svelte-14ysusk.svelte-14ysusk:hover{background:#223}.nav-item.selected.svelte-14ysusk.svelte-14ysusk{background:#2a2a4a;color:#fff;font-weight:500}.nav-empty.svelte-14ysusk.svelte-14ysusk{padding:8px 20px;color:#555;font-size:12px}.sidebar-bottom.svelte-14ysusk.svelte-14ysusk{padding:12px 20px;border-top:1px solid #2a2a3c;flex-shrink:0}.version.svelte-14ysusk.svelte-14ysusk{font-size:11px;color:#555}.main.svelte-14ysusk.svelte-14ysusk{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0;overflow:hidden;background:#13131f}.header.svelte-14ysusk.svelte-14ysusk{padding:12px 24px;border-bottom:1px solid #2a2a3c;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;min-height:48px}.crumb.svelte-14ysusk.svelte-14ysusk{font-size:14px;font-weight:500;color:#e4e4ef}.crumb.placeholder.svelte-14ysusk.svelte-14ysusk{color:#666}.search-hint.svelte-14ysusk.svelte-14ysusk{padding:6px 12px;background:#1e1e2e;border:1px solid #2a2a3c;border-radius:4px;color:#666;font-size:12px;cursor:text}.error-banner.svelte-14ysusk.svelte-14ysusk{background:#3a2222;color:#f88;padding:8px 24px;font-size:12px;border-bottom:1px solid #4a2222;flex-shrink:0}.content.svelte-14ysusk.svelte-14ysusk{flex:1;overflow-y:auto;padding:24px}.welcome.svelte-14ysusk h2.svelte-14ysusk{font-size:28px;font-weight:300;margin-bottom:12px;color:#8888a4}.welcome.svelte-14ysusk p.svelte-14ysusk{color:#666;font-size:13px;margin-bottom:4px}.error-text.svelte-14ysusk.svelte-14ysusk{color:#f88;margin-top:12px}.loading.svelte-14ysusk.svelte-14ysusk{color:#666}.node-view.svelte-14ysusk h2.svelte-14ysusk{font-size:24px;margin-bottom:16px}.node-meta.svelte-14ysusk.svelte-14ysusk{display:flex;gap:16px;color:#666;font-size:12px}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,8 +16,8 @@
|
|||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/index-COs6tJEl.js"></script>
|
<script type="module" crossorigin src="/assets/main-DZkGJWBF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-ClxkTvdE.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-BnVt-oqm.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+24
-3
@@ -7,12 +7,15 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/activity"
|
||||||
|
"verstak/internal/core/config"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
"verstak/internal/core/nodes"
|
"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"
|
||||||
|
syncsvc "verstak/internal/core/sync"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
@@ -43,21 +46,36 @@ func main() {
|
|||||||
|
|
||||||
// Init core services
|
// Init core services
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
fileSvc := files.NewService(db, abs)
|
fileSvc := files.NewService(db, abs, nodeRepo)
|
||||||
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
||||||
actionSvc := actions.NewService(db)
|
actionSvc := actions.NewService(db)
|
||||||
|
activitySvc := activity.NewService(db)
|
||||||
worklogSvc := worklog.NewService(db)
|
worklogSvc := worklog.NewService(db)
|
||||||
searchSvc := search.NewService(db)
|
searchSvc := search.NewService(db)
|
||||||
plugins.NewManager(abs).Discover()
|
pm := plugins.NewManager(abs)
|
||||||
_ = searchSvc
|
pm.Discover()
|
||||||
|
|
||||||
|
// Sync service — use configured device ID or vault ID as fallback.
|
||||||
|
deviceID := ""
|
||||||
|
if cfg, err := config.Load(abs); err == nil {
|
||||||
|
deviceID = cfg.Sync.DeviceID
|
||||||
|
}
|
||||||
|
if deviceID == "" {
|
||||||
|
deviceID = "gui-" + abs[:8]
|
||||||
|
}
|
||||||
|
syncSvc := syncsvc.NewService(db, deviceID)
|
||||||
|
|
||||||
app := &App{
|
app := &App{
|
||||||
db: db,
|
db: db,
|
||||||
nodes: nodeRepo,
|
nodes: nodeRepo,
|
||||||
files: fileSvc,
|
files: fileSvc,
|
||||||
notes: noteSvc,
|
notes: noteSvc,
|
||||||
|
activity: activitySvc,
|
||||||
actions: actionSvc,
|
actions: actionSvc,
|
||||||
worklog: worklogSvc,
|
worklog: worklogSvc,
|
||||||
|
search: searchSvc,
|
||||||
|
plugins: pm,
|
||||||
|
sync: syncSvc,
|
||||||
vault: abs,
|
vault: abs,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +90,9 @@ func main() {
|
|||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.startup,
|
||||||
|
DragAndDrop: &options.DragAndDrop{
|
||||||
|
EnableFileDrop: true,
|
||||||
|
},
|
||||||
Bind: []interface{}{app},
|
Bind: []interface{}{app},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Executable
+110
@@ -0,0 +1,110 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# install.sh — установка Verstak Sync Server
|
||||||
|
#
|
||||||
|
# Использование:
|
||||||
|
# sudo ./install.sh --port 47732 --user verstak --admin-user admin --admin-pass secret
|
||||||
|
#
|
||||||
|
# Флаги:
|
||||||
|
# --port Порт сервера (по умолчанию: 47732)
|
||||||
|
# --user Системный пользователь (по умолчанию: verstak)
|
||||||
|
# --admin-user Логин администратора (обязательный)
|
||||||
|
# --admin-pass Пароль администратора (обязательный)
|
||||||
|
# --bin Путь к бинарнику (по умолчанию: ./verstak-server)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Defaults
|
||||||
|
PORT="${VERSTAK_PORT:-47732}"
|
||||||
|
USER="verstak"
|
||||||
|
ADMIN_USER=""
|
||||||
|
ADMIN_PASS=""
|
||||||
|
BIN="./verstak-server"
|
||||||
|
|
||||||
|
# Parse flags
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--port) PORT="$2"; shift 2 ;;
|
||||||
|
--user) USER="$2"; shift 2 ;;
|
||||||
|
--admin-user) ADMIN_USER="$2"; shift 2 ;;
|
||||||
|
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
|
||||||
|
--bin) BIN="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$ADMIN_USER" ] || [ -z "$ADMIN_PASS" ]; then
|
||||||
|
echo "Usage: $0 --admin-user USER --admin-pass PASS [--port PORT] [--user USER]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "This script must be run as root (sudo)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Verstak Sync Server Installation ==="
|
||||||
|
echo "Port: $PORT"
|
||||||
|
echo "User: $USER"
|
||||||
|
echo "Admin: $ADMIN_USER"
|
||||||
|
echo "Binary: $BIN"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Create system user if not exists.
|
||||||
|
if ! id -u "$USER" >/dev/null 2>&1; then
|
||||||
|
echo "Creating user: $USER"
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Install binary.
|
||||||
|
if [ ! -f "$BIN" ]; then
|
||||||
|
echo "Binary not found: $BIN. Build it first: go build -o $BIN ./cmd/verstak-server/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Installing binary to /usr/local/bin/verstak-server"
|
||||||
|
cp "$BIN" /usr/local/bin/verstak-server
|
||||||
|
chmod 755 /usr/local/bin/verstak-server
|
||||||
|
|
||||||
|
# 3. Create data directory.
|
||||||
|
echo "Creating /var/lib/verstak-server"
|
||||||
|
mkdir -p /var/lib/verstak-server
|
||||||
|
chown "$USER:$USER" /var/lib/verstak-server
|
||||||
|
chmod 750 /var/lib/verstak-server
|
||||||
|
|
||||||
|
# 4. Set up admin user (first run).
|
||||||
|
echo "Setting up admin user"
|
||||||
|
/usr/local/bin/verstak-server \
|
||||||
|
--port "$PORT" \
|
||||||
|
--data /var/lib/verstak-server \
|
||||||
|
--admin-user "$ADMIN_USER" \
|
||||||
|
--admin-pass "$ADMIN_PASS" &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 2
|
||||||
|
kill "$SERVER_PID" 2>/dev/null || true
|
||||||
|
wait "$SERVER_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
# 5. Install systemd unit.
|
||||||
|
echo "Installing systemd unit"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/verstak-server.service"
|
||||||
|
cp "$(dirname "$0")/verstak-server.service" "$SERVICE_FILE"
|
||||||
|
chmod 644 "$SERVICE_FILE"
|
||||||
|
|
||||||
|
# Set port in environment file.
|
||||||
|
mkdir -p /etc/verstak-server
|
||||||
|
echo "VERSTAK_PORT=$PORT" > /etc/verstak-server/env
|
||||||
|
|
||||||
|
# 6. Enable and start.
|
||||||
|
echo "Enabling and starting service"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable verstak-server
|
||||||
|
systemctl start verstak-server
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Installation complete ==="
|
||||||
|
echo "Service: verstak-server"
|
||||||
|
echo "Port: $PORT"
|
||||||
|
echo "Admin: http://localhost:$PORT/admin/login"
|
||||||
|
echo ""
|
||||||
|
echo "Check status: systemctl status verstak-server"
|
||||||
|
echo "View logs: journalctl -u verstak-server -f"
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := flag.Int("port", 47732, "HTTP port")
|
||||||
|
dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)")
|
||||||
|
adminUser := flag.String("admin-user", "", "Create admin user (first run)")
|
||||||
|
adminPass := flag.String("admin-pass", "", "Admin password (first run)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
absData, err := filepath.Abs(*dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("data dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(absData, 0750); err != nil {
|
||||||
|
log.Fatalf("create data dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(absData)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-run admin setup.
|
||||||
|
if *adminUser != "" && *adminPass != "" {
|
||||||
|
if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil {
|
||||||
|
log.Fatalf("set admin: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Admin user %q created.\n", *adminUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open server DB.
|
||||||
|
dbPath := filepath.Join(absData, "server.db")
|
||||||
|
srv, err := NewServer(dbPath, absData, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", *port)
|
||||||
|
log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData)
|
||||||
|
if err := srv.ListenAndServe(addr); err != nil {
|
||||||
|
log.Fatalf("serve: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Verstak Sync Server
|
||||||
|
Documentation=https://github.com/anomalyco/verstak
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/verstak-server --port ${VERSTAK_PORT} --data /var/lib/verstak-server
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
User=verstak
|
||||||
|
Group=verstak
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
StateDirectory=verstak-server
|
||||||
|
RuntimeDirectory=verstak-server
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/config"
|
||||||
"verstak/internal/core/plugins"
|
"verstak/internal/core/plugins"
|
||||||
"verstak/internal/core/search"
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
|
syncsvc "verstak/internal/core/sync"
|
||||||
"verstak/internal/core/vault"
|
"verstak/internal/core/vault"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
@@ -38,6 +40,8 @@ func main() {
|
|||||||
runLog(os.Args[2:])
|
runLog(os.Args[2:])
|
||||||
case "index":
|
case "index":
|
||||||
runIndex(os.Args[2:])
|
runIndex(os.Args[2:])
|
||||||
|
case "sync":
|
||||||
|
runSync(os.Args[2:])
|
||||||
case "plugin":
|
case "plugin":
|
||||||
runPlugin(os.Args[2:])
|
runPlugin(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
@@ -56,6 +60,7 @@ func usage() {
|
|||||||
fmt.Println(" node Manage nodes")
|
fmt.Println(" node Manage nodes")
|
||||||
fmt.Println(" action Manage actions")
|
fmt.Println(" action Manage actions")
|
||||||
fmt.Println(" --version Show version")
|
fmt.Println(" --version Show version")
|
||||||
|
fmt.Println(" sync Sync with server (push/pull/status)")
|
||||||
fmt.Println(" --help Show this help")
|
fmt.Println(" --help Show this help")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,6 +602,162 @@ func runIndexRebuild(args []string) {
|
|||||||
fmt.Printf("indexed %d nodes\n", count)
|
fmt.Printf("indexed %d nodes\n", count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- sync ---
|
||||||
|
|
||||||
|
func runSync(args []string) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Println("verstak sync — synchronize with server")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Usage: verstak sync <command> [options]")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Commands:")
|
||||||
|
fmt.Println(" push Push local changes to server")
|
||||||
|
fmt.Println(" pull Pull remote changes from server")
|
||||||
|
fmt.Println(" status Show sync status")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "push":
|
||||||
|
runSyncPush(args[1:])
|
||||||
|
case "pull":
|
||||||
|
runSyncPull(args[1:])
|
||||||
|
case "status":
|
||||||
|
runSyncStatus(args[1:])
|
||||||
|
case "--help", "-h":
|
||||||
|
runSync(nil)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Unknown sync command: %s\n", args[0])
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSyncDB(args []string) (*storage.DB, string) {
|
||||||
|
vaultPath, _ := stringFlag(args, "--vault")
|
||||||
|
abs, _ := filepath.Abs(vaultPath)
|
||||||
|
dbPath := filepath.Join(abs, ".verstak", "index.db")
|
||||||
|
db, err := storage.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return db, abs
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSyncPush(args []string) {
|
||||||
|
db, abs := openSyncDB(args)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
cfg, err := config.Load(abs)
|
||||||
|
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "Sync not configured. Use 'verstak sync configure' or GUI settings.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID := cfg.Sync.DeviceID
|
||||||
|
if deviceID == "" {
|
||||||
|
deviceID = "cli-" + abs[:8]
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSvc := syncsvc.NewService(db, deviceID)
|
||||||
|
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
|
||||||
|
|
||||||
|
unpushed, err := syncSvc.GetUnpushedOps()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Get ops: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unpushed) == 0 {
|
||||||
|
fmt.Println("Nothing to push.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.Push(unpushed)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Push failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syncSvc.MarkPushed(result.Accepted); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Mark pushed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Pushed %d ops, accepted %d\n", len(unpushed), len(result.Accepted))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSyncPull(args []string) {
|
||||||
|
db, abs := openSyncDB(args)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
cfg, err := config.Load(abs)
|
||||||
|
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "Sync not configured.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID := cfg.Sync.DeviceID
|
||||||
|
if deviceID == "" {
|
||||||
|
deviceID = "cli-" + abs[:8]
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSvc := syncsvc.NewService(db, deviceID)
|
||||||
|
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
|
||||||
|
|
||||||
|
_, _, lastRev, _, err := syncSvc.GetState()
|
||||||
|
if err != nil {
|
||||||
|
lastRev = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.Pull(lastRev)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var opIDs []string
|
||||||
|
for _, op := range result.Ops {
|
||||||
|
fmt.Printf(" %s\t%s\t%s\t%s\n", op.OpType, op.EntityType, op.EntityID, op.PayloadJSON)
|
||||||
|
opIDs = append(opIDs, op.OpID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opIDs) > 0 {
|
||||||
|
syncSvc.MarkApplied(opIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Pulled %d ops (server rev: %d)\n", len(result.Ops), result.ServerRevision)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSyncStatus(args []string) {
|
||||||
|
db, abs := openSyncDB(args)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
cfg, err := config.Load(abs)
|
||||||
|
configured := err == nil && cfg.Sync.ServerURL != "" && cfg.Sync.APIKey != ""
|
||||||
|
serverURL := ""
|
||||||
|
deviceID := ""
|
||||||
|
if cfg != nil {
|
||||||
|
serverURL = cfg.Sync.ServerURL
|
||||||
|
deviceID = cfg.Sync.DeviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
unpushed := 0
|
||||||
|
if configured {
|
||||||
|
if deviceID == "" {
|
||||||
|
deviceID = "cli-" + abs[:8]
|
||||||
|
}
|
||||||
|
syncSvc := syncsvc.NewService(db, deviceID)
|
||||||
|
ops, _ := syncSvc.GetUnpushedOps()
|
||||||
|
unpushed = len(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Sync Status")
|
||||||
|
fmt.Println(" Configured:", configured)
|
||||||
|
fmt.Println(" Server:", serverURL)
|
||||||
|
fmt.Println(" Device:", deviceID)
|
||||||
|
fmt.Println(" Unpushed ops:", unpushed)
|
||||||
|
}
|
||||||
|
|
||||||
// --- plugin ---
|
// --- plugin ---
|
||||||
|
|
||||||
func runPlugin(args []string) {
|
func runPlugin(args []string) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
|
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
|
||||||
8. [[08_MVP_Checklist]] — чеклист первого MVP.
|
8. [[08_MVP_Checklist]] — чеклист первого MVP.
|
||||||
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
|
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
|
||||||
|
10. [[10_Sync_Server_Guide]] — установка и настройка сервера синхронизации.
|
||||||
|
|
||||||
## Главные принципы
|
## Главные принципы
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
┌────────────────────┐
|
┌────────────────────┐
|
||||||
│ GUI/Wails │
|
│ GUI/Wails v2 │
|
||||||
└─────────┬──────────┘
|
└─────────┬──────────┘
|
||||||
│
|
│
|
||||||
┌─────────────┐ ┌───────▼────────┐ ┌─────────────┐
|
┌───────▼────────┐ ┌─────────────┐
|
||||||
│ TUI/Bubble │───▶│ Core Library │◀───│ CLI Commands │
|
│ Core Library │◀───│ CLI Commands │
|
||||||
└─────────────┘ └───────┬────────┘ └─────────────┘
|
└───────┬────────┘ └─────────────┘
|
||||||
│
|
│
|
||||||
┌─────────▼──────────┐
|
┌─────────▼──────────┐
|
||||||
│ Local Vault+SQLite │
|
│ Local Vault+SQLite │
|
||||||
@@ -161,8 +161,6 @@ verstak/
|
|||||||
activity/
|
activity/
|
||||||
search/
|
search/
|
||||||
importers/
|
importers/
|
||||||
sync/
|
|
||||||
security/
|
|
||||||
config/
|
config/
|
||||||
|
|
||||||
gui/
|
gui/
|
||||||
|
|||||||
+8
-8
@@ -18,23 +18,23 @@
|
|||||||
- import/dokuwiki/mysql-cleanup.txt
|
- import/dokuwiki/mysql-cleanup.txt
|
||||||
|
|
||||||
Быстрые действия:
|
Быстрые действия:
|
||||||
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync]
|
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync] *(future)*
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Общий layout
|
## 2. Общий layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
┌────────────────────────────────────────────────────┐
|
┌───────────────┬────────────────────────────────────┐
|
||||||
│ Search | Quick Add | Sync Status | Settings │
|
│ Tree sidebar │ Header: Section/Node Title │
|
||||||
├───────────────┬────────────────────────────────────┤
|
│ ├────────────────────────────────────┤
|
||||||
│ Tree sidebar │ Main content │
|
│ Сегодня │ Main content │
|
||||||
│ │ │
|
│ Клиенты │ Case / Note / File / Search │
|
||||||
│ Сегодня │ Case / Note / File / Search │
|
|
||||||
│ Клиенты │ │
|
|
||||||
│ Проекты │ │
|
│ Проекты │ │
|
||||||
│ Рецепты │ │
|
│ Рецепты │ │
|
||||||
│ Документы │ │
|
│ Документы │ │
|
||||||
└───────────────┴────────────────────────────────────┘
|
└───────────────┴────────────────────────────────────┘
|
||||||
|
|
||||||
|
*Примечание: Quick Add и Sync Status из оригинального макета пока не реализованы — в планах на будущее.*
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Sidebar tree
|
## 3. Sidebar tree
|
||||||
|
|||||||
+9
-5
@@ -1,5 +1,7 @@
|
|||||||
# Верстак — roadmap
|
# Верстак — roadmap
|
||||||
|
|
||||||
|
> **Wails v3 → v2:** The GUI was initially built with Wails v3 but repeatedly hit SIGSEGV on Linux during window close. Downgraded to Wails v2 which is stable. Milestone 4 below reflects the current (desktop) state.
|
||||||
|
|
||||||
## Milestone 0 — Skeleton
|
## Milestone 0 — Skeleton
|
||||||
|
|
||||||
- Go module;
|
- Go module;
|
||||||
@@ -65,6 +67,8 @@ Acceptance:
|
|||||||
|
|
||||||
## Milestone 4 — GUI MVP
|
## Milestone 4 — GUI MVP
|
||||||
|
|
||||||
|
> **Current state:** the GUI is a Wails v2 desktop app. Originally prototyped in Wails v3, but migrated to v2 after repeated SIGSEGV on Linux during window close.
|
||||||
|
|
||||||
- Wails app;
|
- Wails app;
|
||||||
- sidebar tree;
|
- sidebar tree;
|
||||||
- create node/note;
|
- create node/note;
|
||||||
@@ -140,7 +144,7 @@ Acceptance:
|
|||||||
|
|
||||||
- можно постепенно разобрать папку `work`.
|
- можно постепенно разобрать папку `work`.
|
||||||
|
|
||||||
## Milestone 10 — Sync server MVP
|
## Milestone 10 — Sync server MVP (PAUSED)
|
||||||
|
|
||||||
- HTTP server;
|
- HTTP server;
|
||||||
- API key;
|
- API key;
|
||||||
@@ -166,7 +170,7 @@ Acceptance:
|
|||||||
- конфликт не теряет данные;
|
- конфликт не теряет данные;
|
||||||
- можно восстановить vault на новой машине.
|
- можно восстановить vault на новой машине.
|
||||||
|
|
||||||
## Milestone 12 — Activity MVP
|
## Milestone 12 — Activity MVP (PAUSED)
|
||||||
|
|
||||||
- activity events;
|
- activity events;
|
||||||
- opened nodes;
|
- opened nodes;
|
||||||
@@ -179,7 +183,7 @@ Acceptance:
|
|||||||
|
|
||||||
- Верстак предлагает worklog на основе следов.
|
- Верстак предлагает worklog на основе следов.
|
||||||
|
|
||||||
## Milestone 13 — File scanner/watcher
|
## Milestone 13 — File scanner/watcher (PAUSED)
|
||||||
|
|
||||||
- snapshot scanner;
|
- snapshot scanner;
|
||||||
- fsnotify watcher;
|
- fsnotify watcher;
|
||||||
@@ -191,7 +195,7 @@ Acceptance:
|
|||||||
|
|
||||||
- изменения файлов снаружи приложения обнаруживаются.
|
- изменения файлов снаружи приложения обнаруживаются.
|
||||||
|
|
||||||
## Milestone 14 — TUI MVP
|
## Milestone 14 — TUI MVP (PAUSED)
|
||||||
|
|
||||||
- Bubble Tea;
|
- Bubble Tea;
|
||||||
- tree/search;
|
- tree/search;
|
||||||
@@ -203,7 +207,7 @@ Acceptance:
|
|||||||
|
|
||||||
- можно из терминала быстро работать с Верстаком.
|
- можно из терминала быстро работать с Верстаком.
|
||||||
|
|
||||||
## Milestone 15 — 1.0 polish
|
## Milestone 15 — 1.0 polish (PAUSED)
|
||||||
|
|
||||||
- installers;
|
- installers;
|
||||||
- backup export;
|
- backup export;
|
||||||
|
|||||||
+13
-13
@@ -6,19 +6,19 @@
|
|||||||
|
|
||||||
## Обязательные функции
|
## Обязательные функции
|
||||||
|
|
||||||
- [ ] Создать vault.
|
- [x] Создать vault.
|
||||||
- [ ] Создать дерево дел.
|
- [x] Создать дерево дел.
|
||||||
- [ ] Создать Markdown-заметку.
|
- [x] Создать Markdown-заметку.
|
||||||
- [ ] Редактировать Markdown-заметку.
|
- [x] Редактировать Markdown-заметку.
|
||||||
- [ ] Добавить файл в дело.
|
- [x] Добавить файл в дело.
|
||||||
- [ ] Открыть файл системным приложением.
|
- [x] Открыть файл системным приложением.
|
||||||
- [ ] Создать action “Открыть URL”.
|
- [x] Создать action “Открыть URL”.
|
||||||
- [ ] Создать action “Открыть папку”.
|
- [x] Создать action “Открыть папку”.
|
||||||
- [ ] Создать action “Запустить команду”.
|
- [x] Создать action “Запустить команду”.
|
||||||
- [ ] Добавить запись работы.
|
- [x] Добавить запись работы.
|
||||||
- [ ] Скопировать отчёт по работам.
|
- [x] Скопировать отчёт по работам.
|
||||||
- [ ] Поиск по заметкам.
|
- [x] Поиск по заметкам.
|
||||||
- [ ] Поиск по именам файлов.
|
- [x] Поиск по именам файлов.
|
||||||
- [x] Поиск по журналу работ.
|
- [x] Поиск по журналу работ.
|
||||||
- [x] Базовый импорт DokuWiki (плагин).
|
- [x] Базовый импорт DokuWiki (плагин).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Сервер синхронизации Верстак — руководство
|
||||||
|
|
||||||
|
## 1. Зачем нужен сервер
|
||||||
|
|
||||||
|
Сервер позволяет синхронизировать данные между несколькими устройствами (например, рабочий ПК и ноутбук), а также хранить резервную копию vault'ов.
|
||||||
|
|
||||||
|
Он не обязателен — Верстак работает полностью локально и без сервера.
|
||||||
|
|
||||||
|
## 2. Установка
|
||||||
|
|
||||||
|
### Быстрая установка (systemd)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка бинарника
|
||||||
|
go build -o verstak-server ./cmd/verstak-server/
|
||||||
|
|
||||||
|
# Установка (от root)
|
||||||
|
sudo ./verstak-server/install.sh \
|
||||||
|
--port 47732 \
|
||||||
|
--admin-user admin \
|
||||||
|
--admin-pass 'мой-надёжный-пароль'
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт:
|
||||||
|
- создаёт системного пользователя `verstak`;
|
||||||
|
- копирует бинарник в `/usr/local/bin/verstak-server`;
|
||||||
|
- создаёт `/var/lib/verstak-server` для данных;
|
||||||
|
- записывает админа в конфиг;
|
||||||
|
- устанавливает systemd-сервис `verstak-server.service`;
|
||||||
|
- запускает и включает автозапуск.
|
||||||
|
|
||||||
|
### Ручной запуск (для тестирования)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Одной командой
|
||||||
|
./verstak-server \
|
||||||
|
--port 47732 \
|
||||||
|
--data ./server-data \
|
||||||
|
--admin-user admin \
|
||||||
|
--admin-pass 'мой-надёжный-пароль'
|
||||||
|
```
|
||||||
|
|
||||||
|
Флаги:
|
||||||
|
| Флаг | По умолчанию | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `--port` | `47732` | Порт сервера |
|
||||||
|
| `--data` | `./data` | Директория для данных (SQLite + blobs) |
|
||||||
|
| `--admin-user` | — | Имя администратора (создаётся при первом запуске) |
|
||||||
|
| `--admin-pass` | — | Пароль администратора |
|
||||||
|
|
||||||
|
Если не указать `--admin-user` и `--admin-pass`, сервер запустится, но админ-панель будет недоступна (некому будет логиниться).
|
||||||
|
|
||||||
|
## 3. Админ-панель
|
||||||
|
|
||||||
|
### Как открыть
|
||||||
|
|
||||||
|
Откройте в браузере: `http://<сервер>:47732/admin/login`
|
||||||
|
|
||||||
|
Войдите с логином и паролем, указанными при установке.
|
||||||
|
|
||||||
|
### Что там есть
|
||||||
|
|
||||||
|
- **Дашборд** — статистика: количество устройств, количество операций, список API-ключей.
|
||||||
|
- **Управление API-ключами** — просмотр, создание и удаление ключей устройств.
|
||||||
|
|
||||||
|
Сессия живёт 24 часа. После перезапуска сервера все сессии сбрасываются (хранятся в памяти).
|
||||||
|
|
||||||
|
### Несколько админов
|
||||||
|
|
||||||
|
Можно запустить сервер с другими `--admin-user`/`--admin-pass` — добавится второй администратор. Повторный запуск с тем же именем меняет пароль.
|
||||||
|
|
||||||
|
## 4. API-ключи (устройства)
|
||||||
|
|
||||||
|
### Что такое API-ключ
|
||||||
|
|
||||||
|
API-ключ — это токен, который клиент (Верстак на вашем ноутбуке или ПК) использует для доступа к серверу. Без ключа push/pull/blobs не работают.
|
||||||
|
|
||||||
|
### Как создать
|
||||||
|
|
||||||
|
Через админ-панель:
|
||||||
|
1. Зайти в `/admin/dashboard`.
|
||||||
|
2. В разделе "API Keys" ввести имя устройства и нажать "Create".
|
||||||
|
3. Скопировать сгенерированный ключ.
|
||||||
|
|
||||||
|
Через API (требует логин и пароль администратора):
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:47732/api/v1/device/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"мой-ноутбук","username":"admin","password":"пароль-админа"}'
|
||||||
|
```
|
||||||
|
Ответ: `{"device_id":"...","api_key":"..."}`
|
||||||
|
|
||||||
|
**Важно:** не выставляйте сервер в интернет без HTTPS (через reverse proxy). До создания полноценной системы пользователей регистрация устройств требует учётных данных администратора.
|
||||||
|
|
||||||
|
### Как использовать
|
||||||
|
|
||||||
|
Ключ передаётся в заголовке `Authorization: Bearer <ключ>`:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:47732/api/v1/sync/push \
|
||||||
|
-H "Authorization: Bearer b10c5d8e3f2a..." \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"device_id":"b10c5d8e3f2a","ops":[...]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
В клиенте Верстак достаточно вбить URL сервера и API-ключ в настройках (GUI Settings или `config.yml`).
|
||||||
|
|
||||||
|
### Один ключ на все устройства или отдельный на каждое?
|
||||||
|
|
||||||
|
**На каждое устройство — отдельный ключ.** Так вы сможете отозвать доступ конкретному устройству (удалить ключ в админ-панели), не затронув остальные.
|
||||||
|
|
||||||
|
Технически один и тот же ключ можно использовать на нескольких устройствах, но:
|
||||||
|
- его нельзя будет удалить, не отключив все устройства разом;
|
||||||
|
- в логах сервера все операции будут выглядеть как от одного устройства;
|
||||||
|
- это не поддерживаемый сценарий.
|
||||||
|
|
||||||
|
**Рекомендация:** создавайте отдельный ключ для каждого клиента (ПК, ноутбук, телефон).
|
||||||
|
|
||||||
|
## 5. Настройка клиента
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
```bash
|
||||||
|
verstak sync push --vault /путь/к/vault
|
||||||
|
verstak sync pull --vault /путь/к/vault
|
||||||
|
verstak sync status --vault /путь/к/vault
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед использованием нужно указать URL сервера и API-ключ в `.verstak/config.yml` внутри vault:
|
||||||
|
```yaml
|
||||||
|
sync:
|
||||||
|
server_url: http://мой-сервер:47732
|
||||||
|
api_key: мой-ключ
|
||||||
|
device_id: id-устройства # опционально
|
||||||
|
auto_sync: false
|
||||||
|
```
|
||||||
|
|
||||||
|
### GUI
|
||||||
|
В графическом интерфейсе нажмите на иконку шестерёнки в левом нижнем углу → откроется окно настроек синхронизации. Укажите URL сервера и API-ключ, нажмите "Сохранить". Кнопка "Синхронизировать" запускает push + pull.
|
||||||
|
|
||||||
|
## 6. Безопасность
|
||||||
|
|
||||||
|
Сервер **не поддерживает HTTPS**. В production используйте reverse proxy (nginx, Caddy) для терминирования TLS.
|
||||||
|
|
||||||
|
Что стоит учесть:
|
||||||
|
- Регистрация устройств открыта — любой, кто достучался до сервера, может создать ключ.
|
||||||
|
- Нет logout'а — сессия живёт 24 часа или до перезапуска сервера.
|
||||||
|
- Нет rate limiting'а — возможен перебор пароля.
|
||||||
|
- Пароль хранится в bcrypt — база данных не должна быть общедоступной.
|
||||||
|
|
||||||
|
Рекомендации для production:
|
||||||
|
- Закрыть порт сервера фаерволом (только доверенные IP).
|
||||||
|
- Использовать VPN (WireGuard/OpenVPN) для доступа между устройствами.
|
||||||
|
- Или поставить nginx/Caddy перед сервером с HTTPS и базовой аутентификацией на `/api/v1/device/register`.
|
||||||
|
|
||||||
|
## 7. Полный API
|
||||||
|
|
||||||
|
### Открытые endpoint'ы
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/v1/health` | Проверка здоровья сервера |
|
||||||
|
|
||||||
|
### Требуют API-ключ (Authorization: Bearer)
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/v1/sync/push` | Отправить операции на сервер |
|
||||||
|
| POST | `/api/v1/sync/pull` | Получить операции с сервера |
|
||||||
|
| POST | `/api/v1/blobs/` | Загрузить blob (multipart) |
|
||||||
|
| GET | `/api/v1/blobs/{sha256}` | Скачать blob |
|
||||||
|
|
||||||
|
### Требуют логин+пароль администратора
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/v1/device/register` | Регистрация устройства (body: name + username + password) |
|
||||||
|
|
||||||
|
### Требуют сессию админа (cookie)
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| GET/POST | `/admin/login` | Страница входа / отправка формы |
|
||||||
|
| GET | `/admin/dashboard` | Дашборд |
|
||||||
|
| GET | `/admin/api/keys` | Список API-ключей (JSON) |
|
||||||
|
| POST | `/admin/api/keys` | Создать ключ |
|
||||||
|
| DELETE | `/admin/api/keys/{id}` | Удалить ключ |
|
||||||
+94
-61
@@ -21,21 +21,21 @@
|
|||||||
| 8 | FTS5 Search | ✅ выполнен |
|
| 8 | FTS5 Search | ✅ выполнен |
|
||||||
| 9 | Section assignment + Sidebar filtering | ✅ выполнен |
|
| 9 | Section assignment + Sidebar filtering | ✅ выполнен |
|
||||||
| 10 | Plugin Manager (discovery + templates) | ✅ выполнен |
|
| 10 | Plugin Manager (discovery + templates) | ✅ выполнен |
|
||||||
| 11 | **Wails Desktop GUI** | 🔄 Wails v2 vertical MVP |
|
| 11 | **Wails Desktop GUI** | ✅ выполнено (v2, full Svelte UI) |
|
||||||
| 12 | **Files/Folders full workflow** | ⬜ следующий этап после vertical MVP |
|
| 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) |
|
||||||
| 13 | **Drag-and-drop** | ⬜ не начат |
|
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
|
||||||
| 14 | **MVP stabilization** | ⬜ не начат |
|
| 14 | **MVP stabilization** | 🔄 в процессе — smoke-тесты, docs, go test |
|
||||||
| 15 | Sync Server Skeleton | 🔒 PAUSED |
|
| 15 | Sync Server + Client | 🔒 PAUSED — HTTP API key, push/pull, blob sync |
|
||||||
| 16 | Sync Client MVP | 🔒 PAUSED |
|
| 16 | Activity Suggestions | 🔒 PAUSED — worklog suggestions from activity_events |
|
||||||
| 17 | Activity + File Scanner/Watcher | 🔒 PAUSED |
|
| 17 | File Scanner/Watcher | 🔒 PAUSED — fsnotify, snapshot scanner, missing file detection |
|
||||||
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED |
|
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED — tree/search, add worklog, run action, sync |
|
||||||
| 19 | Integrity Check + Repair | 🔒 PAUSED |
|
| 19 | Plugins: Lua runtime | 🔒 PAUSED — gopher-lua, hooks, sandbox |
|
||||||
| 20 | Plugins: Lua runtime | 🔒 PAUSED |
|
| 20 | Browser Extension | 🔒 PAUSED — tracking, capture, evidence |
|
||||||
| 21 | DokuWiki Importer (plugin) | 🔒 PAUSED |
|
| 21 | Calendar/Kanban | 🔒 PAUSED — view by date, board view |
|
||||||
| 22 | Calendar/Kanban | 🔒 PAUSED |
|
| 22 | Integrity Check + Repair | 🔒 PAUSED — checksums, crash recovery |
|
||||||
| 23 | New templates/integrations | 🔒 PAUSED |
|
| 23 | New templates/integrations | 🔒 PAUSED — community plugins |
|
||||||
|
|
||||||
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization).
|
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization). Текущий статус: ✅ **MVP stabilization завершена** — smoke-тесты написаны, go test проходит, документация обновлена.
|
||||||
|
|
||||||
> **Wails v3 → v2 migration:** Wails v3 alpha.96 показал SIGSEGV на Linux desktop (GTK/X11). Wails v2 stable выбран как GUI base для MVP. Миграция в процессе (ветка `gui/migrate-wails-v2`).
|
> **Wails v3 → v2 migration:** Wails v3 alpha.96 показал SIGSEGV на Linux desktop (GTK/X11). Wails v2 stable выбран как GUI base для MVP. Миграция в процессе (ветка `gui/migrate-wails-v2`).
|
||||||
|
|
||||||
@@ -47,35 +47,37 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
|||||||
./verstak-gui
|
./verstak-gui
|
||||||
```
|
```
|
||||||
|
|
||||||
**GUI Build (Wails v2):**
|
|
||||||
```bash
|
|
||||||
cd frontend && npm run build && cd ..
|
|
||||||
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
|
|
||||||
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
|
||||||
./verstak-gui
|
|
||||||
```
|
|
||||||
|
|
||||||
Или для dev режима: `wails dev` (требует Wails v2 CLI)
|
Или для dev режима: `wails dev` (требует Wails v2 CLI)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Текущий этап: Wails v2 Vertical MVP
|
## Текущий этап: MVP Stabilization
|
||||||
|
|
||||||
**Цель:** базовый рабочий desktop GUI для разделов → дел → заметок.
|
**Цель:** стабилизация MVP — smoke-тесты, go test, документация.
|
||||||
|
|
||||||
**Прогресс:**
|
**Прогресс Wails v2 Desktop GUI:**
|
||||||
- ✅ Wails v2 shell (window opens, no SIGSEGV)
|
- ✅ Wails v2 shell (window opens, no SIGSEGV)
|
||||||
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
|
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
|
||||||
- 🔄 Notes bindings + UI
|
- ✅ Notes bindings + UI (create/read/save/dirty state)
|
||||||
- 🔄 Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
|
- ✅ Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
|
||||||
- 🔄 Node creation
|
- ✅ Node creation + template selection (FromTemplate)
|
||||||
- 🔄 Section filtering
|
- ✅ Section filtering
|
||||||
|
- ✅ File tree with breadcrumbs, preview, CRUD (rename/delete/duplicate/cut/copy/paste)
|
||||||
|
- ✅ Drag-and-drop (internal + external OS file drops)
|
||||||
|
- ✅ Actions CRUD (create/list/run/delete)
|
||||||
|
- ✅ Worklog entry form
|
||||||
|
- ✅ Today dashboard + Activity feed (global + per-case)
|
||||||
|
- ✅ Search
|
||||||
|
- ✅ Import dialog with safety checks (PreviewImport)
|
||||||
|
- ✅ Keyboard shortcuts
|
||||||
|
|
||||||
**Пауза (не начинать до завершения vertical MVP):**
|
**Среднесрочные шаги (заморожены до стабилизации MVP):**
|
||||||
- Файлы/папки workflow
|
- Sync server/client
|
||||||
- Drag-and-drop
|
- File Scanner/Watcher
|
||||||
- Sync, plugins, Lua, browser extension, TUI
|
- TUI (Bubble Tea)
|
||||||
- Новые шаблоны, DokuWiki importer
|
- Activity suggestions
|
||||||
|
- Lua runtime
|
||||||
|
- Browser extension
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -175,38 +177,65 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
|
|||||||
4. Перенести текущий UI shell из inline HTML в frontend
|
4. Перенести текущий UI shell из inline HTML в frontend
|
||||||
5. Оставить текущий `internal/gui/` как legacy (не удалять, но не развивать)
|
5. Оставить текущий `internal/gui/` как legacy (не удалять, но не развивать)
|
||||||
|
|
||||||
### Backend bindings (минимум)
|
### Backend bindings (Wails v2 — реализовано)
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Nodes
|
// Nodes
|
||||||
ListSections() ([]SectionDTO, error)
|
ListSections() []SectionDTO
|
||||||
ListNodes(section string) ([]NodeDTO, error)
|
ListNodesBySection(section string) ([]NodeDTO, error)
|
||||||
GetNodeDetail(nodeID string) (NodeDetailDTO, error)
|
GetNodeDetail(nodeID string) (*NodeDTO, error)
|
||||||
CreateNode(parentID, section, typ, title string) (NodeDTO, error)
|
ListChildren(parentID string) ([]NodeDTO, error)
|
||||||
FromTemplate(parentID, section, typ, title, template string) (NodeDTO, error)
|
CreateNode(parentID, typ, title, section string) (*NodeDTO, error)
|
||||||
DeleteNode(id string) error
|
DeleteNode(id string) error
|
||||||
MoveNode(id, parentID string) error
|
MoveNode(nodeID, newParentID string) error
|
||||||
|
RenameNode(nodeID, newTitle string) error
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
ListTemplates() []TemplateDTO
|
||||||
|
FromTemplate(parentID, typ, title, section, template string) (*NodeDTO, error)
|
||||||
|
|
||||||
// Notes
|
// Notes
|
||||||
CreateNote(parentID, title string) (NodeDTO, error)
|
ListNotes(nodeID string) ([]NodeDTO, error)
|
||||||
|
CreateNote(parentID, title string) (*NodeDTO, error)
|
||||||
ReadNote(noteID string) (string, error)
|
ReadNote(noteID string) (string, error)
|
||||||
SaveNote(noteID, content string) error
|
SaveNote(noteID, content string) error
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
ListFiles(nodeID string) ([]FileDTO, error)
|
ListFiles(nodeID string) ([]FileDTO, error)
|
||||||
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) // файл или папка
|
ListItems(nodeID string) ([]FileTreeItemDTO, error)
|
||||||
|
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error)
|
||||||
AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error)
|
AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error)
|
||||||
DeleteFileOrFolder(id string) error
|
DeleteFileOrFolder(nodeID string) error
|
||||||
OpenFile(id string) error
|
CreateEmptyFile(parentID, filename string) (*NodeDTO, error)
|
||||||
OpenFolder(id string) error
|
DuplicateNode(nodeID string) (*NodeDTO, error)
|
||||||
|
OpenFile(fileID string) error
|
||||||
|
OpenFolder(nodeID string) error
|
||||||
|
ReadFileText(fileID string) (string, error)
|
||||||
|
GetFileBase64(fileID string) (string, error)
|
||||||
|
PreviewImport(sourcePath string) (*ImportSummary, error)
|
||||||
|
ValidateName(name string) error
|
||||||
PickFile() (string, error)
|
PickFile() (string, error)
|
||||||
|
PickFiles() ([]string, error)
|
||||||
PickDirectory() (string, error)
|
PickDirectory() (string, error)
|
||||||
|
|
||||||
// Actions/Worklog/Search
|
// Actions
|
||||||
ListActions(nodeID string) ([]ActionDTO, error)
|
ListActions(nodeID string) ([]ActionDTO, error)
|
||||||
|
CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
|
||||||
|
DeleteAction(id string) error
|
||||||
RunAction(id string) error
|
RunAction(id string) error
|
||||||
CreateWorklog(nodeID, summary string, minutes int) (WorklogDTO, error)
|
|
||||||
|
// Worklog
|
||||||
|
ListWorklog(nodeID string) ([]WorklogDTO, error)
|
||||||
|
CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error)
|
||||||
|
|
||||||
|
// Search
|
||||||
Search(query string) ([]SearchResultDTO, error)
|
Search(query string) ([]SearchResultDTO, error)
|
||||||
|
|
||||||
|
// Activity
|
||||||
|
ListTodayView() (*TodayDashboardDTO, error)
|
||||||
|
ListActivityFeed(limit, offset int) ([]EventDTO, error)
|
||||||
|
ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error)
|
||||||
|
CountActivityByNode(nodeID string) (int, error)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -311,18 +340,22 @@ verstak/
|
|||||||
|
|
||||||
cmd/
|
cmd/
|
||||||
verstak/ # CLI
|
verstak/ # CLI
|
||||||
verstak-gui/ # Wails GUI main
|
verstak-gui/ # Wails v2 GUI main
|
||||||
verstak-tui/ # Bubble Tea TUI
|
|
||||||
verstak-server/ # Sync server
|
|
||||||
|
|
||||||
frontend/ # Wails frontend
|
frontend/ # Wails v2 Svelte frontend
|
||||||
package.json
|
package.json
|
||||||
wails.json
|
vite.config.js
|
||||||
src/
|
src/
|
||||||
App.svelte
|
App.svelte
|
||||||
components/
|
FileTreeRow.svelte
|
||||||
stores/
|
lib/
|
||||||
styles/
|
FileBreadcrumbs.svelte
|
||||||
|
FilePreviewModal.svelte
|
||||||
|
ConfirmModal.svelte
|
||||||
|
FileIcon.svelte
|
||||||
|
FileActions.svelte
|
||||||
|
fileUtils.js
|
||||||
|
api/verstak.js
|
||||||
|
|
||||||
internal/
|
internal/
|
||||||
core/
|
core/
|
||||||
@@ -335,10 +368,8 @@ verstak/
|
|||||||
worklog/
|
worklog/
|
||||||
activity/
|
activity/
|
||||||
search/
|
search/
|
||||||
sync/
|
|
||||||
security/
|
|
||||||
config/
|
config/
|
||||||
plugins/ # manager, lua (stub)
|
plugins/ # manager, lua (stub), builtin templates
|
||||||
|
|
||||||
contrib/
|
contrib/
|
||||||
plugins/
|
plugins/
|
||||||
@@ -351,11 +382,13 @@ verstak/
|
|||||||
004_add_notes.sql
|
004_add_notes.sql
|
||||||
005_add_actions.sql
|
005_add_actions.sql
|
||||||
006_add_worklog.sql
|
006_add_worklog.sql
|
||||||
|
007_search.sql
|
||||||
|
008_sync.sql
|
||||||
|
009_section.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
## RAID
|
## RAID
|
||||||
|
|
||||||
- **Критично:** Wails требует Node.js для frontend-сборки
|
- **Критично:** Wails требует Node.js для frontend-сборки
|
||||||
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
|
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
|
||||||
- **Зависимость:** Steps 15+ ждут завершения step 14
|
- **Зависимость:** Steps 15+ ждут завершения step 14 (MVP stabilization)
|
||||||
- **Риск:** Wails v3 может быть нестабилен — проверить перед шагом 11
|
|
||||||
|
|||||||
+1692
-224
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,392 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
import FileIcon from './lib/FileIcon.svelte'
|
||||||
|
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
|
||||||
|
|
||||||
|
export let item
|
||||||
|
export let selected = false
|
||||||
|
export let onDragStart
|
||||||
|
export let onDragOver
|
||||||
|
export let onDrop
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
const kind = getFileKind(item)
|
||||||
|
const isFolder = item.type === 'folder'
|
||||||
|
const fileType = formatFileType(item)
|
||||||
|
let menuOpen = false
|
||||||
|
let clickTimer = null
|
||||||
|
|
||||||
|
function handleClick(e) {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
dispatch('toggleSelect', item.id)
|
||||||
|
} else if (e.shiftKey) {
|
||||||
|
dispatch('rangeSelect', item.id)
|
||||||
|
} else {
|
||||||
|
if (clickTimer) {
|
||||||
|
clearTimeout(clickTimer)
|
||||||
|
clickTimer = null
|
||||||
|
// Double click → открыть
|
||||||
|
if (isFolder) {
|
||||||
|
dispatch('navigate', item.id)
|
||||||
|
} else {
|
||||||
|
dispatch('preview', item)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clickTimer = setTimeout(() => {
|
||||||
|
clickTimer = null
|
||||||
|
// Single click → выделить
|
||||||
|
dispatch('selectOne', item.id)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenExternal() {
|
||||||
|
dispatch('openExternal', item.fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShowInFolder() {
|
||||||
|
dispatch('showInFolder', item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
menuOpen = false
|
||||||
|
dispatch('delete', { id: item.id, type: item.type })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRename() {
|
||||||
|
menuOpen = false
|
||||||
|
dispatch('rename', { id: item.id, name: item.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDuplicate() {
|
||||||
|
menuOpen = false
|
||||||
|
dispatch('duplicate', item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCut() {
|
||||||
|
menuOpen = false
|
||||||
|
dispatch('cut', item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
menuOpen = false
|
||||||
|
dispatch('copy', item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
menuOpen = false
|
||||||
|
if (isFolder) {
|
||||||
|
dispatch('navigate', item.id)
|
||||||
|
} else {
|
||||||
|
dispatch('preview', item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
menuOpen = !menuOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
menuOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragStart(e) {
|
||||||
|
if (onDragStart) onDragStart(e, item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
if (onDragOver && isFolder) onDragOver(e, item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
if (onDrop && isFolder) onDrop(e, item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function oncontextmenu(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
menuOpen = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:click={closeMenu}/>
|
||||||
|
|
||||||
|
<div class="file-row"
|
||||||
|
class:file-row--selected={selected}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
draggable="true"
|
||||||
|
on:click={handleClick}
|
||||||
|
on:contextmenu={oncontextmenu}
|
||||||
|
on:dragstart={handleDragStart}
|
||||||
|
on:dragover={handleDragOver}
|
||||||
|
on:drop={handleDrop}
|
||||||
|
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
|
||||||
|
<div class="file-row-icon">
|
||||||
|
<FileIcon {kind} size={22}/>
|
||||||
|
</div>
|
||||||
|
<div class="file-row-body">
|
||||||
|
<div class="file-row-name" title={item.name}>{item.name}</div>
|
||||||
|
<div class="file-row-meta">
|
||||||
|
<span>{fileType}</span>
|
||||||
|
{#if !isFolder}
|
||||||
|
<span class="meta-sep">·</span>
|
||||||
|
<span>{formatFileSize(item.size)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-row-actions">
|
||||||
|
{#if !isFolder}
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<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">
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<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">
|
||||||
|
<circle cx="12" cy="5" r="2"/>
|
||||||
|
<circle cx="12" cy="12" r="2"/>
|
||||||
|
<circle cx="12" cy="19" r="2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if menuOpen}
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
||||||
|
<div class="menu" on:click|stopPropagation role="menu">
|
||||||
|
<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>
|
||||||
|
Открыть
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
Открыть во внешней программе
|
||||||
|
</button>
|
||||||
|
{#if isFolder}
|
||||||
|
<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>
|
||||||
|
Показать в проводнике
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
<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>
|
||||||
|
Переименовать
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
Дублировать
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
Вырезать
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
Копировать
|
||||||
|
</button>
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
<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>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: default;
|
||||||
|
transition: background 0.12s;
|
||||||
|
min-height: 52px;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row:hover {
|
||||||
|
background: #1e1e30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row--selected {
|
||||||
|
background: #1e1e3a;
|
||||||
|
outline: 1px solid #3a3a6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row--selected:hover {
|
||||||
|
background: #252545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row:focus-visible {
|
||||||
|
outline: 2px solid #5588ff;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ddd;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-sep {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row:hover .file-row-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: #1a1a28;
|
||||||
|
border: 1px solid #2a2a3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 220px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background: #2a2a3c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-danger {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-danger:hover {
|
||||||
|
background: #3a2222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:focus-visible {
|
||||||
|
outline: 2px solid #5588ff;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: #2a2a3c;
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+38
-36
@@ -1,43 +1,45 @@
|
|||||||
// Wails v2 API wrapper — single frontend access point to Go backend
|
// Wails v2 API wrapper — uses generated bindings from wailsjs/go/main/App.js
|
||||||
|
import * as App from '../wailsjs/go/main/App.js'
|
||||||
|
|
||||||
function wailsCall(method, ...args) {
|
// Re-export all methods
|
||||||
if (window.go && window.go.main && window.go.main.App) {
|
export const listSections = () => App.ListSections()
|
||||||
return window.go.main.App[method](...args)
|
export const listTodayView = () => App.ListTodayView()
|
||||||
}
|
export const listNodesBySection = (section) => App.ListNodesBySection(section)
|
||||||
return Promise.reject(new Error('Wails bindings not loaded'))
|
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)
|
||||||
|
|
||||||
// Sections
|
export const listNotes = (nodeID) => App.ListNotes(nodeID)
|
||||||
export const listSections = () => wailsCall('ListSections')
|
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)
|
||||||
|
|
||||||
// Nodes
|
export const listFiles = (nodeID) => App.ListFiles(nodeID)
|
||||||
export const listNodesBySection = (section) => wailsCall('ListNodesBySection', section)
|
export const listItems = (nodeID) => App.ListItems(nodeID)
|
||||||
export const listChildren = (parentID) => wailsCall('ListChildren', parentID)
|
export const addPathCopy = (nodeID, sourcePath) => App.AddPathCopy(nodeID, sourcePath)
|
||||||
export const getNodeDetail = (id) => wailsCall('GetNodeDetail', id)
|
export const addPathLink = (nodeID, sourcePath) => App.AddPathLink(nodeID, sourcePath)
|
||||||
export const createNode = (parentID, type, title, section) =>
|
export const deleteFileOrFolder = (nodeID) => App.DeleteFileOrFolder(nodeID)
|
||||||
wailsCall('CreateNode', parentID, type, title, section)
|
export const previewImport = (sourcePath) => App.PreviewImport(sourcePath)
|
||||||
export const deleteNode = (id) => wailsCall('DeleteNode', id)
|
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)
|
||||||
|
|
||||||
// Notes
|
export const listActions = (nodeID) => App.ListActions(nodeID)
|
||||||
export const listNotes = (nodeID) => wailsCall('ListNotes', nodeID)
|
export const runAction = (id) => App.RunAction(id)
|
||||||
export const createNote = (parentID, title) => wailsCall('CreateNote', parentID, title)
|
|
||||||
export const readNote = (noteID) => wailsCall('ReadNote', noteID)
|
|
||||||
export const saveNote = (noteID, content) => wailsCall('SaveNote', noteID, content)
|
|
||||||
|
|
||||||
// Files
|
export const listWorklog = (nodeID) => App.ListWorklog(nodeID)
|
||||||
export const listFiles = (nodeID) => wailsCall('ListFiles', nodeID)
|
export const createWorklog = (nodeID, summary, minutes) => App.CreateWorklog(nodeID, summary, minutes)
|
||||||
|
|
||||||
// Actions
|
export const search = (query) => App.Search(query)
|
||||||
export const listActions = (nodeID) => wailsCall('ListActions', nodeID)
|
|
||||||
export const runAction = (id) => wailsCall('RunAction', id)
|
|
||||||
|
|
||||||
// Worklog
|
export const verstakVersion = () => App.VerstakVersion()
|
||||||
export const listWorklog = (nodeID) => wailsCall('ListWorklog', nodeID)
|
|
||||||
export const createWorklog = (nodeID, summary, minutes) =>
|
|
||||||
wailsCall('CreateWorklog', nodeID, summary, minutes)
|
|
||||||
|
|
||||||
// Search
|
|
||||||
export const search = (query) => wailsCall('Search', query)
|
|
||||||
|
|
||||||
// System
|
|
||||||
export const verstakVersion = () => wailsCall('VerstakVersion')
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script>
|
||||||
|
export let title = 'Подтверждение'
|
||||||
|
export let message = ''
|
||||||
|
export let confirmText = 'Удалить'
|
||||||
|
export let cancelText = 'Отмена'
|
||||||
|
export let danger = false
|
||||||
|
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overlay" on:click|self={() => dispatch('cancel')} role="dialog" aria-modal="true" aria-label={title}>
|
||||||
|
<div class="modal">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p class="message">{message}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn {danger ? 'btn-danger' : 'btn-primary'}" on:click={() => dispatch('confirm')}>{confirmText}</button>
|
||||||
|
<button class="btn" on:click={() => dispatch('cancel')}>{cancelText}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #1a1a28;
|
||||||
|
border: 1px solid #2a2a3c;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
width: 360px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #e4e4ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #2a2a3c;
|
||||||
|
background: #1a1a28;
|
||||||
|
color: #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: #222233;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #6366f1;
|
||||||
|
border-color: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid #5588ff;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
|
export let crumbs = []
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
function navigateTo(index) {
|
||||||
|
dispatch('navigate', index)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="breadcrumbs">
|
||||||
|
{#each crumbs as crumb, i}
|
||||||
|
{#if i > 0}
|
||||||
|
<span class="sep">/</span>
|
||||||
|
{/if}
|
||||||
|
{#if i === crumbs.length - 1}
|
||||||
|
<span class="crumb crumb--current">{crumb.name}</span>
|
||||||
|
{:else}
|
||||||
|
<button class="crumb crumb--link" on:click={() => navigateTo(i)}>{crumb.name}</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb--current {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb--link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb--link:hover {
|
||||||
|
color: #ccc;
|
||||||
|
background: #1e1e30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb--link:focus-visible {
|
||||||
|
outline: 2px solid #5588ff;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script>
|
||||||
|
export let kind = 'generic'
|
||||||
|
export let size = 20
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{#if kind === 'folder'}
|
||||||
|
<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"/>
|
||||||
|
{:else if kind === 'image'}
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
{:else if kind === 'video'}
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||||
|
<polyline points="10 9 16 12 10 15 10 9"/>
|
||||||
|
{:else if kind === 'audio'}
|
||||||
|
<path d="M9 18V5l12-2v13"/>
|
||||||
|
<circle cx="6" cy="18" r="3"/>
|
||||||
|
<circle cx="18" cy="16" r="3"/>
|
||||||
|
{:else if kind === 'pdf'}
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||||
|
<line x1="8" y1="16" x2="16" y2="16"/>
|
||||||
|
<line x1="8" y1="14" x2="12" y2="14"/>
|
||||||
|
{:else if kind === 'document'}
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
{:else if kind === 'spreadsheet'}
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||||
|
<line x1="8" y1="16" x2="16" y2="16"/>
|
||||||
|
<line x1="8" y1="14" x2="12" y2="14"/>
|
||||||
|
<line x1="12" y1="12" x2="12" y2="18"/>
|
||||||
|
{:else if kind === 'presentation'}
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="9" y1="12" x2="15" y2="12"/>
|
||||||
|
<line x1="9" y1="15" x2="13" y2="15"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="18"/>
|
||||||
|
{:else if kind === 'archive'}
|
||||||
|
<path d="M21 8v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"/>
|
||||||
|
<polyline points="7 3 12 8 17 3"/>
|
||||||
|
<line x1="3" y1="8" x2="21" y2="8"/>
|
||||||
|
<rect x="10" y="12" width="4" height="4" rx="1"/>
|
||||||
|
{:else if kind === 'code'}
|
||||||
|
<polyline points="16 18 22 12 16 6"/>
|
||||||
|
<polyline points="8 6 2 12 8 18"/>
|
||||||
|
{:else if kind === 'text'}
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
{:else}
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
|
||||||
|
import FileIcon from './FileIcon.svelte'
|
||||||
|
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
|
||||||
|
|
||||||
|
export let item
|
||||||
|
export let content = '' // base64 data URI or text content
|
||||||
|
export let loading = false
|
||||||
|
export let error = ''
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
const kind = getFileKind(item)
|
||||||
|
$: showImage = isImageFile(item) && content && content.startsWith('data:')
|
||||||
|
$: showText = isTextFile(item) || isMarkdownFile(item)
|
||||||
|
$: showPdf = isPdfFile(item)
|
||||||
|
|
||||||
|
function handleKeydown(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
dispatch('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenExternal() {
|
||||||
|
dispatch('openExternal', item.fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overlay" on:click|self={() => dispatch('close')} role="dialog" aria-modal="true" aria-label={`Preview: ${item.name}`}>
|
||||||
|
<div class="modal">
|
||||||
|
<header class="preview-header">
|
||||||
|
<div class="preview-title">
|
||||||
|
<FileIcon {kind} size={18}/>
|
||||||
|
<span class="preview-name" title={item.name}>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
||||||
|
<div class="preview-actions">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn-close" on:click={() => dispatch('close')} title="Close" aria-label="Close preview">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="preview-body">
|
||||||
|
{#if loading}
|
||||||
|
<div class="preview-status"><p>Загрузка...</p></div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="preview-status">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
|
</div>
|
||||||
|
{:else if showImage && content}
|
||||||
|
<div class="preview-image-container">
|
||||||
|
<img src={content} alt={item.name} class="preview-image"/>
|
||||||
|
</div>
|
||||||
|
{:else if showText && content}
|
||||||
|
<pre class="preview-text"><code>{content}</code></pre>
|
||||||
|
{:else if showPdf}
|
||||||
|
{#if content && content.startsWith('data:')}
|
||||||
|
<div class="preview-pdf-container">
|
||||||
|
<embed src={content} type="application/pdf" class="preview-pdf"/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="preview-status">
|
||||||
|
<p>Предпросмотр PDF недоступен.</p>
|
||||||
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="preview-status">
|
||||||
|
<p>Предпросмотр недоступен для этого типа файлов.</p>
|
||||||
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #14141f;
|
||||||
|
border: 1px solid #2a2a3c;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 900px;
|
||||||
|
height: 85vh;
|
||||||
|
max-height: 700px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #2a2a3c;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
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:focus-visible {
|
||||||
|
outline: 2px solid #5588ff;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-close {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-close:hover {
|
||||||
|
background: #3a2222;
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 200px;
|
||||||
|
background: #0e0e18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(85vh - 100px);
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-text {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #ccc;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pdf-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pdf {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid #2a2a3c;
|
||||||
|
background: #1a1a28;
|
||||||
|
color: #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm:hover {
|
||||||
|
background: #222233;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
export function formatFileSize(bytes) {
|
||||||
|
if (bytes == null || bytes < 0) return '—'
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||||
|
const val = bytes / Math.pow(1024, i)
|
||||||
|
return (i === 0 ? val.toFixed(0) : val.toFixed(1)) + ' ' + units[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeLabels = {
|
||||||
|
'image/jpeg': 'Изображение JPEG',
|
||||||
|
'image/png': 'Изображение PNG',
|
||||||
|
'image/gif': 'Изображение GIF',
|
||||||
|
'image/webp': 'Изображение WebP',
|
||||||
|
'image/svg+xml': 'Изображение SVG',
|
||||||
|
'image/bmp': 'Изображение BMP',
|
||||||
|
'image/tiff': 'Изображение TIFF',
|
||||||
|
'image/avif': 'Изображение AVIF',
|
||||||
|
'application/pdf': 'PDF документ',
|
||||||
|
'application/msword': 'Документ Word',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
|
||||||
|
'application/vnd.ms-excel': 'Таблица Excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
|
||||||
|
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
|
||||||
|
'application/zip': 'ZIP архив',
|
||||||
|
'application/gzip': 'GZIP архив',
|
||||||
|
'application/x-tar': 'TAR архив',
|
||||||
|
'application/x-7z-compressed': '7z архив',
|
||||||
|
'application/x-rar-compressed': 'RAR архив',
|
||||||
|
'text/plain': 'Текстовый файл',
|
||||||
|
'text/html': 'HTML файл',
|
||||||
|
'text/css': 'CSS файл',
|
||||||
|
'text/javascript': 'JavaScript файл',
|
||||||
|
'application/json': 'JSON файл',
|
||||||
|
'application/xml': 'XML файл',
|
||||||
|
'application/x-yaml': 'YAML файл',
|
||||||
|
'application/octet-stream': 'Бинарный файл',
|
||||||
|
'application/x-msdos-program': 'Исполняемый файл',
|
||||||
|
'inode/directory': 'Папка',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMimeType(mime) {
|
||||||
|
if (!mime) return 'Неизвестно'
|
||||||
|
return mimeLabels[mime] || mime
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileType(item) {
|
||||||
|
if (item.type === 'folder') return 'Папка'
|
||||||
|
const mime = (item.mime || '').toLowerCase()
|
||||||
|
if (mimeLabels[mime]) return mimeLabels[mime]
|
||||||
|
const name = (item.name || '').toLowerCase()
|
||||||
|
const ext = name.split('.').pop()
|
||||||
|
if (ext) return ext.toUpperCase()
|
||||||
|
return 'Файл'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileKind(item) {
|
||||||
|
if (item.type === 'folder') return 'folder'
|
||||||
|
const mime = (item.mime || '').toLowerCase()
|
||||||
|
if (mime.startsWith('image/')) return 'image'
|
||||||
|
if (mime.startsWith('video/')) return 'video'
|
||||||
|
if (mime.startsWith('audio/')) return 'audio'
|
||||||
|
if (mime.startsWith('text/')) return 'text'
|
||||||
|
if (mime.includes('pdf')) return 'pdf'
|
||||||
|
if (mime.includes('word') || mime.includes('document')) return 'document'
|
||||||
|
if (mime.includes('spreadsheet') || mime.includes('excel')) return 'spreadsheet'
|
||||||
|
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'presentation'
|
||||||
|
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gzip') || mime.includes('rar') || mime.includes('7z') || mime.includes('compress')) return 'archive'
|
||||||
|
if (mime.includes('json') || mime.includes('xml') || mime.includes('yaml') || mime.includes('javascript') || mime.includes('css') || mime.includes('html')) return 'code'
|
||||||
|
const name = (item.name || '').toLowerCase()
|
||||||
|
const ext = name.split('.').pop()
|
||||||
|
const codeExts = ['js','ts','jsx','tsx','vue','svelte','py','rs','go','c','cpp','h','hpp','java','kt','swift','rb','php','pl','sh','bash','zsh','fish','yml','yaml','json','xml','toml','ini','cfg','conf','md','markdown','css','scss','less','sass','sql','graphql','proto','gradle','cmake','makefile','dockerfile','env','gitignore']
|
||||||
|
if (codeExts.includes(ext)) return 'code'
|
||||||
|
return 'generic'
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageMimes = ['image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/avif','image/svg+xml']
|
||||||
|
const textMimes = ['text/plain','text/html','text/css','text/javascript','application/json','application/xml','application/x-yaml','text/x-shellscript']
|
||||||
|
const codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','sh','py','js','ts','css','html','md','markdown','cfg']
|
||||||
|
|
||||||
|
const imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg']
|
||||||
|
|
||||||
|
export function canPreviewFile(item) {
|
||||||
|
if (item.type === 'folder') return false
|
||||||
|
const mime = (item.mime || '').toLowerCase()
|
||||||
|
const name = (item.name || '').toLowerCase()
|
||||||
|
const ext = name.split('.').pop()
|
||||||
|
if (imageMimes.includes(mime) || imageExts.includes(ext)) return true
|
||||||
|
if (mime.includes('pdf')) return true
|
||||||
|
if (textMimes.includes(mime) || codeNames.includes(ext)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageFile(item) {
|
||||||
|
const mime = (item.mime || '').toLowerCase()
|
||||||
|
const name = (item.name || '').toLowerCase()
|
||||||
|
const ext = name.split('.').pop()
|
||||||
|
return imageMimes.includes(mime) || imageExts.includes(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTextFile(item) {
|
||||||
|
const mime = (item.mime || '').toLowerCase()
|
||||||
|
const name = (item.name || '').toLowerCase()
|
||||||
|
const ext = name.split('.').pop()
|
||||||
|
return textMimes.includes(mime) || (codeNames.includes(ext) && ext !== 'md' && ext !== 'markdown')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPdfFile(item) {
|
||||||
|
return (item.mime || '').toLowerCase().includes('pdf')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMarkdownFile(item) {
|
||||||
|
const name = (item.name || '').toLowerCase()
|
||||||
|
return name.endsWith('.md') || name.endsWith('.markdown')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsBase64Preview(item) {
|
||||||
|
return isImageFile(item) || isPdfFile(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsTextPreview(item) {
|
||||||
|
return isTextFile(item) || isMarkdownFile(item)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Wails v2 generated bindings — auto-generated by wails build
|
||||||
|
// Manual version for go build -tags gui
|
||||||
|
|
||||||
|
export function ListSections() {
|
||||||
|
return window['go']['main']['App']['ListSections']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListTodayView() {
|
||||||
|
return window['go']['main']['App']['ListTodayView']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListNodesBySection(arg1) {
|
||||||
|
return window['go']['main']['App']['ListNodesBySection'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListChildren(arg1) {
|
||||||
|
return window['go']['main']['App']['ListChildren'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetNodeDetail(arg1) {
|
||||||
|
return window['go']['main']['App']['GetNodeDetail'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateNode(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['main']['App']['CreateNode'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteNode(arg1) {
|
||||||
|
return window['go']['main']['App']['DeleteNode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListNotes(arg1) {
|
||||||
|
return window['go']['main']['App']['ListNotes'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateNote(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['CreateNote'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReadNote(arg1) {
|
||||||
|
return window['go']['main']['App']['ReadNote'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveNote(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['SaveNote'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListFiles(arg1) {
|
||||||
|
return window['go']['main']['App']['ListFiles'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddPathCopy(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AddPathCopy'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddPathLink(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AddPathLink'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteFileOrFolder(arg1) {
|
||||||
|
return window['go']['main']['App']['DeleteFileOrFolder'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PreviewImport(arg1) {
|
||||||
|
return window['go']['main']['App']['PreviewImport'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListActions(arg1) {
|
||||||
|
return window['go']['main']['App']['ListActions'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunAction(arg1) {
|
||||||
|
return window['go']['main']['App']['RunAction'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListWorklog(arg1) {
|
||||||
|
return window['go']['main']['App']['ListWorklog'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateWorklog(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['CreateWorklog'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Search(arg1) {
|
||||||
|
return window['go']['main']['App']['Search'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PickFile() {
|
||||||
|
return window['go']['main']['App']['PickFile']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PickFiles() {
|
||||||
|
return window['go']['main']['App']['PickFiles']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PickDirectory() {
|
||||||
|
return window['go']['main']['App']['PickDirectory']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenFile(arg1) {
|
||||||
|
return window['go']['main']['App']['OpenFile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenFolder(arg1) {
|
||||||
|
return window['go']['main']['App']['OpenFolder'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerstakVersion() {
|
||||||
|
return window['go']['main']['App']['VerstakVersion']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListActivityFeed(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ListActivityFeed'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListActivityByNode(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['ListActivityByNode'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountActivityByNode(arg1) {
|
||||||
|
return window['go']['main']['App']['CountActivityByNode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateEmptyFile(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2);
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
server: {
|
server: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 3001,
|
port: 3001,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
plugins: [svelte()],
|
|
||||||
build: {
|
build: {
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, "index.html"),
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
publicDir: "public",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package activity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"verstak/internal/core/storage"
|
||||||
|
"verstak/internal/core/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event types.
|
||||||
|
const (
|
||||||
|
TypeNoteCreated = "note_created"
|
||||||
|
TypeNoteUpdated = "note_updated"
|
||||||
|
TypeFileAdded = "file_added"
|
||||||
|
TypeFileDeleted = "file_deleted"
|
||||||
|
TypeFileRenamed = "file_renamed"
|
||||||
|
TypeFileCopied = "file_copied"
|
||||||
|
TypeFileMoved = "file_moved"
|
||||||
|
TypeFolderAdded = "folder_added"
|
||||||
|
TypeFolderDeleted = "folder_deleted"
|
||||||
|
TypeFolderRenamed = "folder_renamed"
|
||||||
|
TypeNodeCreated = "node_created"
|
||||||
|
TypeNodeUpdated = "node_updated"
|
||||||
|
TypeActionCreated = "action_created"
|
||||||
|
TypeActionDone = "action_done"
|
||||||
|
TypeWorklogAdded = "worklog_added"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target types.
|
||||||
|
const (
|
||||||
|
TargetNote = "note"
|
||||||
|
TargetFile = "file"
|
||||||
|
TargetFolder = "folder"
|
||||||
|
TargetAction = "action"
|
||||||
|
TargetNode = "node"
|
||||||
|
TargetWorklog = "worklog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event represents an activity event.
|
||||||
|
type Event struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
TargetType string `json:"target_type,omitempty"`
|
||||||
|
TargetID string `json:"target_id,omitempty"`
|
||||||
|
TargetPath string `json:"target_path,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
DetailsJSON string `json:"details_json,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service records and queries activity events.
|
||||||
|
type Service struct {
|
||||||
|
db *storage.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *storage.DB) *Service {
|
||||||
|
return &Service{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record inserts a new activity event.
|
||||||
|
func (s *Service) Record(nodeID, targetType, targetID, targetPath, eventType, title, detailsJSON string) error {
|
||||||
|
id := util.UUID7()
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||||
|
id, nodeID, eventType, targetType, targetID, targetPath, title, detailsJSON, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TodayBoundaries returns the current local day in UTC as RFC3339 strings.
|
||||||
|
func TodayBoundaries() (string, string) {
|
||||||
|
now := time.Now()
|
||||||
|
y, m, d := now.Date()
|
||||||
|
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
end := start.Add(24 * time.Hour)
|
||||||
|
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanEvent scans a single event row.
|
||||||
|
func scanEvent(rows *sql.Rows) (Event, error) {
|
||||||
|
var e Event
|
||||||
|
err := rows.Scan(&e.ID, &e.NodeID, &e.EventType,
|
||||||
|
&e.TargetType, &e.TargetID, &e.TargetPath,
|
||||||
|
&e.Title, &e.DetailsJSON, &e.CreatedAt)
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTodayEvents returns all activity events from today.
|
||||||
|
func (s *Service) ListTodayEvents() ([]Event, error) {
|
||||||
|
start, end := TodayBoundaries()
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
|
||||||
|
title,COALESCE(metadata,'{}'),created_at
|
||||||
|
FROM activity_events
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
ORDER BY created_at DESC`, start, end)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var events []Event
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanEvent(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTodayEventsByParent returns today's events grouped by their NodeID.
|
||||||
|
func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
|
||||||
|
events, err := s.ListTodayEvents()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
grouped := make(map[string][]Event, 8)
|
||||||
|
for _, e := range events {
|
||||||
|
pid := e.NodeID
|
||||||
|
grouped[pid] = append(grouped[pid], e)
|
||||||
|
}
|
||||||
|
return grouped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRecent returns a paginated global activity feed.
|
||||||
|
func (s *Service) ListRecent(limit, offset int) ([]Event, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
|
||||||
|
title,COALESCE(metadata,'{}'),created_at
|
||||||
|
FROM activity_events
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?`, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var events []Event
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanEvent(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByNode returns paginated events for a specific node.
|
||||||
|
func (s *Service) ListByNode(nodeID string, limit, offset int) ([]Event, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
|
||||||
|
title,COALESCE(metadata,'{}'),created_at
|
||||||
|
FROM activity_events
|
||||||
|
WHERE node_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?`, nodeID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var events []Event
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanEvent(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByNode returns the total number of events for a node.
|
||||||
|
func (s *Service) CountByNode(nodeID string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM activity_events WHERE node_id = ?`, nodeID).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ type SyncConfig struct {
|
|||||||
APIKey string `yaml:"api_key"`
|
APIKey string `yaml:"api_key"`
|
||||||
DeviceID string `yaml:"device_id"`
|
DeviceID string `yaml:"device_id"`
|
||||||
AutoSync bool `yaml:"auto_sync"`
|
AutoSync bool `yaml:"auto_sync"`
|
||||||
|
SyncInterval int `yaml:"sync_interval"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrowserConfig struct {
|
type BrowserConfig struct {
|
||||||
|
|||||||
+396
-8
@@ -3,6 +3,7 @@ package files
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"verstak/internal/core/nodes"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
"verstak/internal/core/util"
|
"verstak/internal/core/util"
|
||||||
)
|
)
|
||||||
@@ -32,15 +34,25 @@ type Record struct {
|
|||||||
Missing bool `json:"missing"`
|
Missing bool `json:"missing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImportSummary describes a scanned directory before import.
|
||||||
|
type ImportSummary struct {
|
||||||
|
Files int `json:"files"`
|
||||||
|
Folders int `json:"folders"`
|
||||||
|
TotalBytes int64 `json:"totalBytes"`
|
||||||
|
IsDangerous bool `json:"isDangerous"`
|
||||||
|
DangerReason string `json:"dangerReason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Service provides file operations inside a vault.
|
// Service provides file operations inside a vault.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *storage.DB
|
db *storage.DB
|
||||||
vaultRoot string
|
vaultRoot string
|
||||||
|
nodes *nodes.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a file service bound to a vault.
|
// NewService creates a file service bound to a vault.
|
||||||
func NewService(db *storage.DB, vaultRoot string) *Service {
|
func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository) *Service {
|
||||||
return &Service{db: db, vaultRoot: vaultRoot}
|
return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB returns the underlying storage.
|
// DB returns the underlying storage.
|
||||||
@@ -48,6 +60,59 @@ func (s *Service) DB() *storage.DB {
|
|||||||
return s.db
|
return s.db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- security helpers ---
|
||||||
|
|
||||||
|
// ValidateName is an exported wrapper for validateName.
|
||||||
|
func ValidateName(name string) error {
|
||||||
|
return validateName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateName rejects filenames with path separators, relative components,
|
||||||
|
// and other dangerous patterns.
|
||||||
|
func validateName(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("name is required")
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
||||||
|
return fmt.Errorf("name must not contain path separators")
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "..") {
|
||||||
|
return fmt.Errorf("name must not contain '..'")
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "\x00") {
|
||||||
|
return fmt.Errorf("name must not contain null bytes")
|
||||||
|
}
|
||||||
|
if len(name) > 255 {
|
||||||
|
return fmt.Errorf("name too long (max 255)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// vaultPath resolves a relative vault path and checks it stays within jail.
|
||||||
|
func (s *Service) vaultPath(rel string) (string, error) {
|
||||||
|
abs := filepath.Join(s.vaultRoot, rel)
|
||||||
|
cleaned := filepath.Clean(abs)
|
||||||
|
if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) {
|
||||||
|
return "", fmt.Errorf("path escapes vault root")
|
||||||
|
}
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// absPathSafe resolves an absolute path and checks jail if it's under vault.
|
||||||
|
// For "external" mode records the path stored may be an absolute external path.
|
||||||
|
// This function only checks path safety — it does not enforce that external
|
||||||
|
// files must be inside the vault.
|
||||||
|
func (s *Service) absPathSafe(rec *Record) (string, error) {
|
||||||
|
if rec.StorageMode == "vault" {
|
||||||
|
return s.vaultPath(rec.Path)
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(rec.Path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("abs: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Clean(abs), nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- public operations ---
|
// --- public operations ---
|
||||||
|
|
||||||
// AddExternal registers an external file (absolute path) without copying.
|
// AddExternal registers an external file (absolute path) without copying.
|
||||||
@@ -72,6 +137,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
|
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
|
||||||
|
if _, err := s.vaultPath(filepath.Join("spaces", nodeSlug)); err != nil {
|
||||||
|
return nil, fmt.Errorf("path safety: %w", err)
|
||||||
|
}
|
||||||
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||||
return nil, fmt.Errorf("mkdir: %w", err)
|
return nil, fmt.Errorf("mkdir: %w", err)
|
||||||
}
|
}
|
||||||
@@ -93,6 +161,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||||
|
if _, err := s.vaultPath(relPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("path safety: %w", err)
|
||||||
|
}
|
||||||
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
|
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if rec.StorageMode == "vault" {
|
if rec.StorageMode == "vault" {
|
||||||
src := filepath.Join(s.vaultRoot, rec.Path)
|
src, err := s.vaultPath(rec.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||||
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
|
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
|
||||||
|
// verify trash is inside vault
|
||||||
|
if _, err := s.vaultPath(filepath.Join(".verstak", "trash", rec.ID+"_"+rec.Filename)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := os.Rename(src, dest); err != nil {
|
if err := os.Rename(src, dest); err != nil {
|
||||||
return fmt.Errorf("move to trash: %w", err)
|
return fmt.Errorf("move to trash: %w", err)
|
||||||
}
|
}
|
||||||
@@ -157,15 +235,325 @@ func (s *Service) Open(id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var abs string
|
abs, err := s.absPathSafe(rec)
|
||||||
if rec.StorageMode == "vault" {
|
if err != nil {
|
||||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
return err
|
||||||
} else {
|
|
||||||
abs = rec.Path
|
|
||||||
}
|
}
|
||||||
return openWithSystem(abs)
|
return openWithSystem(abs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxPreviewSize is the maximum file size (5 MB) for inline preview.
|
||||||
|
const maxPreviewSize = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
// ReadText reads a file's content as text, up to maxPreviewSize.
|
||||||
|
func (s *Service) ReadText(id string) (string, error) {
|
||||||
|
rec, err := s.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if rec.Size > maxPreviewSize {
|
||||||
|
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||||
|
}
|
||||||
|
abs, err := s.absPathSafe(rec)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read: %w", err)
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBase64 reads a file and returns a data URI (base64-encoded).
|
||||||
|
func (s *Service) ReadBase64(id string) (string, error) {
|
||||||
|
rec, err := s.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if rec.Size > maxPreviewSize {
|
||||||
|
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||||
|
}
|
||||||
|
abs, err := s.absPathSafe(rec)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read: %w", err)
|
||||||
|
}
|
||||||
|
mime := rec.MIME
|
||||||
|
if mime == "" {
|
||||||
|
mime = "application/octet-stream"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEmptyFile creates a file node and an empty vault file.
|
||||||
|
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
|
||||||
|
if err := validateName(filename); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid filename: %w", err)
|
||||||
|
}
|
||||||
|
filename = s.uniqueTitle(parentID, filename)
|
||||||
|
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return nil, fmt.Errorf("mkdir: %w", err)
|
||||||
|
}
|
||||||
|
dest := filepath.Join(dir, filename)
|
||||||
|
f, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create file: %w", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||||
|
// Verify dest is inside vault
|
||||||
|
if _, err := s.vaultPath(relPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("path safety: %w", err)
|
||||||
|
}
|
||||||
|
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
|
||||||
|
return node, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate creates a copy of a node and its file record under the same parent.
|
||||||
|
func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||||
|
original, err := s.nodes.GetActive(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parentID := ""
|
||||||
|
if original.ParentID != nil {
|
||||||
|
parentID = *original.ParentID
|
||||||
|
}
|
||||||
|
newName := s.copyTitle(parentID, original.Title)
|
||||||
|
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if original.Type == nodes.TypeFile {
|
||||||
|
records, _ := s.ListByNode(original.ID)
|
||||||
|
if len(records) > 0 {
|
||||||
|
src := &records[0]
|
||||||
|
if src.StorageMode == "vault" {
|
||||||
|
srcPath, err := s.vaultPath(src.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
||||||
|
os.MkdirAll(dir, 0o750)
|
||||||
|
dst := filepath.Join(dir, newName)
|
||||||
|
hash, err := copyAndHash(srcPath, dst)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("copy file: %w", err)
|
||||||
|
}
|
||||||
|
relPath, _ := filepath.Rel(s.vaultRoot, dst)
|
||||||
|
if _, err := s.vaultPath(relPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("path safety: %w", err)
|
||||||
|
}
|
||||||
|
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// External file: create a new record pointing to the same absolute path.
|
||||||
|
_, err = s.insertRecord(node.ID, newName, src.Path, "external", src.Size, src.SHA256)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPathCopy copies sourcePath (file or directory) into the vault under nodeID.
|
||||||
|
func (s *Service) AddPathCopy(nodeID, sourcePath string) ([]nodes.Node, error) {
|
||||||
|
return s.importPath(nodeID, sourcePath, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPathLink links sourcePath (file or directory) without copying into vault.
|
||||||
|
func (s *Service) AddPathLink(nodeID, sourcePath string) ([]nodes.Node, error) {
|
||||||
|
return s.importPath(nodeID, sourcePath, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewImport scans sourcePath and returns a summary without importing.
|
||||||
|
func (s *Service) PreviewImport(sourcePath string) (*ImportSummary, error) {
|
||||||
|
info, err := os.Stat(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return &ImportSummary{Files: 1, TotalBytes: info.Size()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sum ImportSummary
|
||||||
|
err = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
sum.Folders++
|
||||||
|
name := strings.ToLower(fi.Name())
|
||||||
|
if name == ".git" || name == "node_modules" || name == ".cache" {
|
||||||
|
sum.IsDangerous = true
|
||||||
|
sum.DangerReason = fmt.Sprintf("содержит %s", fi.Name())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sum.Files++
|
||||||
|
sum.TotalBytes += fi.Size()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if sum.Files > 1000 && !sum.IsDangerous {
|
||||||
|
sum.IsDangerous = true
|
||||||
|
sum.DangerReason = "более 1000 файлов"
|
||||||
|
}
|
||||||
|
if sum.TotalBytes > 1<<30 && !sum.IsDangerous {
|
||||||
|
sum.IsDangerous = true
|
||||||
|
sum.DangerReason = "более 1 GB"
|
||||||
|
}
|
||||||
|
return &sum, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNodeAndChildren soft-deletes a node and all descendants,
|
||||||
|
// moving vault files to trash.
|
||||||
|
func (s *Service) DeleteNodeAndChildren(nodeID string) error {
|
||||||
|
children, _ := s.nodes.ListChildren(nodeID, false)
|
||||||
|
for i := range children {
|
||||||
|
if err := s.DeleteNodeAndChildren(children[i].ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = s.deleteFileRecords(nodeID)
|
||||||
|
return s.nodes.SoftDelete(nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) deleteFileRecords(nodeID string) error {
|
||||||
|
records, err := s.ListByNode(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, r := range records {
|
||||||
|
_ = s.DeleteToTrash(r.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]nodes.Node, error) {
|
||||||
|
info, err := os.Stat(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
title := s.uniqueTitle(parentID, filepath.Base(sourcePath))
|
||||||
|
node, err := s.nodes.Create(parentID, nodes.TypeFile, title, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if copyMode {
|
||||||
|
_, err = s.CopyIntoVault(node.ID, sourcePath, node.Slug)
|
||||||
|
} else {
|
||||||
|
_, err = s.AddExternal(node.ID, sourcePath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []nodes.Node{*node}, nil
|
||||||
|
}
|
||||||
|
return s.importDir(parentID, sourcePath, info.Name(), copyMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool) ([]nodes.Node, error) {
|
||||||
|
dirName = s.uniqueTitle(parentID, dirName)
|
||||||
|
folderNode, err := s.nodes.Create(parentID, nodes.TypeFolder, dirName, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var all []nodes.Node
|
||||||
|
all = append(all, *folderNode)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
childPath := filepath.Join(sourcePath, entry.Name())
|
||||||
|
if entry.IsDir() {
|
||||||
|
children, err := s.importDir(folderNode.ID, childPath, entry.Name(), copyMode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, children...)
|
||||||
|
} else {
|
||||||
|
childNode, err := s.nodes.Create(folderNode.ID, nodes.TypeFile, entry.Name(), "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if copyMode {
|
||||||
|
_, err = s.CopyIntoVault(childNode.ID, childPath, childNode.Slug)
|
||||||
|
} else {
|
||||||
|
_, err = s.AddExternal(childNode.ID, childPath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, *childNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) uniqueTitle(parentID, desired string) string {
|
||||||
|
children, _ := s.nodes.ListChildren(parentID, false)
|
||||||
|
used := make(map[string]bool, len(children))
|
||||||
|
for i := range children {
|
||||||
|
used[children[i].Title] = true
|
||||||
|
}
|
||||||
|
if !used[desired] {
|
||||||
|
return desired
|
||||||
|
}
|
||||||
|
for n := 2; ; n++ {
|
||||||
|
c := fmt.Sprintf("%s (%d)", desired, n)
|
||||||
|
if !used[c] {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyTitle generates a unique "Name (copy).ext" style name for duplicates.
|
||||||
|
// For files with extensions: "photo.jpg" → "photo (copy).jpg", "photo (copy 2).jpg"
|
||||||
|
// For folders: "Docs" → "Docs (copy)", "Docs (copy 2)"
|
||||||
|
func (s *Service) copyTitle(parentID, desired string) string {
|
||||||
|
children, _ := s.nodes.ListChildren(parentID, false)
|
||||||
|
used := make(map[string]bool, len(children))
|
||||||
|
for i := range children {
|
||||||
|
used[children[i].Title] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(desired)
|
||||||
|
base := strings.TrimSuffix(desired, ext)
|
||||||
|
copyName := base + " (copy)" + ext
|
||||||
|
if !used[copyName] {
|
||||||
|
return copyName
|
||||||
|
}
|
||||||
|
for n := 2; ; n++ {
|
||||||
|
candidate := fmt.Sprintf("%s (copy %d)%s", base, n, ext)
|
||||||
|
if !used[candidate] {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniqueTitleCopy returns a copy-style unique name for use in conflict resolution.
|
||||||
|
func (s *Service) UniqueTitleCopy(parentID, desired string) string {
|
||||||
|
return s.copyTitle(parentID, desired)
|
||||||
|
}
|
||||||
|
|
||||||
// --- implementation details ---
|
// --- implementation details ---
|
||||||
|
|
||||||
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"verstak/internal/core/nodes"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ func TestAddExternal(t *testing.T) {
|
|||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
// Run migration 002 manually since storage.Open already applied it.
|
// Run migration 002 manually since storage.Open already applied it.
|
||||||
// We can verify the table exists by inserting.
|
// We can verify the table exists by inserting.
|
||||||
filesSvc := NewService(db, t.TempDir())
|
filesSvc := NewService(db, t.TempDir(), nodes.NewRepository(db))
|
||||||
|
|
||||||
// Create a real temp file to register.
|
// Create a real temp file to register.
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -62,7 +63,7 @@ func TestAddExternal(t *testing.T) {
|
|||||||
func TestCopyIntoVault(t *testing.T) {
|
func TestCopyIntoVault(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
vaultRoot := t.TempDir()
|
vaultRoot := t.TempDir()
|
||||||
svc := NewService(db, vaultRoot)
|
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
|
||||||
|
|
||||||
// Source file.
|
// Source file.
|
||||||
srcDir := t.TempDir()
|
srcDir := t.TempDir()
|
||||||
@@ -88,7 +89,7 @@ func TestCopyIntoVault(t *testing.T) {
|
|||||||
|
|
||||||
func TestListByNode(t *testing.T) {
|
func TestListByNode(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
svc := NewService(db, t.TempDir())
|
svc := NewService(db, t.TempDir(), nodes.NewRepository(db))
|
||||||
|
|
||||||
os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640)
|
os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640)
|
||||||
f1 := filepath.Join(t.TempDir(), "a1.txt")
|
f1 := filepath.Join(t.TempDir(), "a1.txt")
|
||||||
@@ -111,7 +112,7 @@ func TestListByNode(t *testing.T) {
|
|||||||
func TestDeleteToTrash(t *testing.T) {
|
func TestDeleteToTrash(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
vaultRoot := t.TempDir()
|
vaultRoot := t.TempDir()
|
||||||
svc := NewService(db, vaultRoot)
|
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
|
||||||
|
|
||||||
src := filepath.Join(t.TempDir(), "important.pdf")
|
src := filepath.Join(t.TempDir(), "important.pdf")
|
||||||
os.WriteFile(src, []byte("important data"), 0o640)
|
os.WriteFile(src, []byte("important data"), 0o640)
|
||||||
@@ -140,6 +141,170 @@ func TestDeleteToTrash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAddPathCopySingleFile(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
vaultRoot := t.TempDir()
|
||||||
|
nodeRepo := nodes.NewRepository(db)
|
||||||
|
svc := NewService(db, vaultRoot, nodeRepo)
|
||||||
|
|
||||||
|
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
||||||
|
src := filepath.Join(t.TempDir(), "doc.pdf")
|
||||||
|
os.WriteFile(src, []byte("file content"), 0o640)
|
||||||
|
|
||||||
|
nodes, err := svc.AddPathCopy(parent.ID, src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddPathCopy: %v", err)
|
||||||
|
}
|
||||||
|
if len(nodes) != 1 {
|
||||||
|
t.Fatalf("got %d nodes, want 1", len(nodes))
|
||||||
|
}
|
||||||
|
if nodes[0].Type != "file" {
|
||||||
|
t.Errorf("type = %q", nodes[0].Type)
|
||||||
|
}
|
||||||
|
// Source intact.
|
||||||
|
if _, err := os.Stat(src); err != nil {
|
||||||
|
t.Error("source should remain intact")
|
||||||
|
}
|
||||||
|
// File record created.
|
||||||
|
records, _ := svc.ListByNode(nodes[0].ID)
|
||||||
|
if len(records) != 1 {
|
||||||
|
t.Errorf("file records = %d", len(records))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPathLinkSingleFile(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
vaultRoot := t.TempDir()
|
||||||
|
nodeRepo := nodes.NewRepository(db)
|
||||||
|
svc := NewService(db, vaultRoot, nodeRepo)
|
||||||
|
|
||||||
|
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
||||||
|
src := filepath.Join(t.TempDir(), "linked.pdf")
|
||||||
|
os.WriteFile(src, []byte("linked"), 0o640)
|
||||||
|
|
||||||
|
nodes, err := svc.AddPathLink(parent.ID, src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddPathLink: %v", err)
|
||||||
|
}
|
||||||
|
if len(nodes) != 1 {
|
||||||
|
t.Fatalf("got %d nodes, want 1", len(nodes))
|
||||||
|
}
|
||||||
|
// File record should have external storage mode.
|
||||||
|
records, _ := svc.ListByNode(nodes[0].ID)
|
||||||
|
if len(records) != 1 {
|
||||||
|
t.Fatalf("file records = %d", len(records))
|
||||||
|
}
|
||||||
|
if records[0].StorageMode != "external" {
|
||||||
|
t.Errorf("storage mode = %q, want external", records[0].StorageMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPathCopyDirectory(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
vaultRoot := t.TempDir()
|
||||||
|
nodeRepo := nodes.NewRepository(db)
|
||||||
|
svc := NewService(db, vaultRoot, nodeRepo)
|
||||||
|
|
||||||
|
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
|
||||||
|
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o640)
|
||||||
|
os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("bb"), 0o640)
|
||||||
|
|
||||||
|
nodes, err := svc.AddPathCopy(parent.ID, srcDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddPathCopy dir: %v", err)
|
||||||
|
}
|
||||||
|
// Should create: folder node + file node + sub folder node + file node in sub.
|
||||||
|
if len(nodes) < 3 {
|
||||||
|
t.Errorf("expected 3+ nodes, got %d", len(nodes))
|
||||||
|
}
|
||||||
|
// Verify structure: root folder + children.
|
||||||
|
var folders, files int
|
||||||
|
for i := range nodes {
|
||||||
|
if nodes[i].Type == "folder" {
|
||||||
|
folders++
|
||||||
|
} else {
|
||||||
|
files++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if folders < 1 {
|
||||||
|
t.Error("expected at least 1 folder")
|
||||||
|
}
|
||||||
|
if files < 1 {
|
||||||
|
t.Error("expected at least 1 file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteNodeAndChildren(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
vaultRoot := t.TempDir()
|
||||||
|
nodeRepo := nodes.NewRepository(db)
|
||||||
|
svc := NewService(db, vaultRoot, nodeRepo)
|
||||||
|
|
||||||
|
parent, _ := nodeRepo.Create("", "case", "To Delete", "")
|
||||||
|
child, _ := nodeRepo.Create(parent.ID, "file", "child.txt", "")
|
||||||
|
// Add file record to child.
|
||||||
|
src := filepath.Join(t.TempDir(), "child.txt")
|
||||||
|
os.WriteFile(src, []byte("data"), 0o640)
|
||||||
|
svc.CopyIntoVault(child.ID, src, child.Slug)
|
||||||
|
|
||||||
|
if err := svc.DeleteNodeAndChildren(parent.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteNodeAndChildren: %v", err)
|
||||||
|
}
|
||||||
|
// Parent should be soft-deleted.
|
||||||
|
if _, err := nodeRepo.GetActive(parent.ID); err == nil {
|
||||||
|
t.Error("parent should be deleted")
|
||||||
|
}
|
||||||
|
// Child should be soft-deleted.
|
||||||
|
if _, err := nodeRepo.GetActive(child.ID); err == nil {
|
||||||
|
t.Error("child should be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNameConflict(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
vaultRoot := t.TempDir()
|
||||||
|
nodeRepo := nodes.NewRepository(db)
|
||||||
|
svc := NewService(db, vaultRoot, nodeRepo)
|
||||||
|
|
||||||
|
parent, _ := nodeRepo.Create("", "case", "Test", "")
|
||||||
|
src := filepath.Join(t.TempDir(), "conflict.pdf")
|
||||||
|
os.WriteFile(src, []byte("data"), 0o640)
|
||||||
|
|
||||||
|
// Import twice with same filename.
|
||||||
|
n1, _ := svc.AddPathCopy(parent.ID, src)
|
||||||
|
n2, _ := svc.AddPathCopy(parent.ID, src)
|
||||||
|
if n1[0].Title == n2[0].Title {
|
||||||
|
t.Error("expected unique name on conflict")
|
||||||
|
}
|
||||||
|
if n2[0].Title == "conflict.pdf" {
|
||||||
|
t.Errorf("title unchanged = %q", n2[0].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewImportDir(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
vaultRoot := t.TempDir()
|
||||||
|
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
|
||||||
|
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
|
||||||
|
os.WriteFile(filepath.Join(srcDir, "f1.txt"), []byte("hello"), 0o640)
|
||||||
|
os.WriteFile(filepath.Join(srcDir, "f2.txt"), []byte("world"), 0o640)
|
||||||
|
|
||||||
|
sum, err := svc.PreviewImport(srcDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PreviewImport: %v", err)
|
||||||
|
}
|
||||||
|
if sum.Files != 2 {
|
||||||
|
t.Errorf("files = %d, want 2", sum.Files)
|
||||||
|
}
|
||||||
|
if sum.Folders != 2 { // root + sub
|
||||||
|
t.Errorf("folders = %d, want 2", sum.Folders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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",
|
||||||
|
|||||||
@@ -50,16 +50,10 @@ func now() string {
|
|||||||
return time.Now().UTC().Format(time.RFC3339)
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CRUD ---
|
|
||||||
|
|
||||||
// Valid sections for root-level nodes.
|
|
||||||
var validSections = map[string]struct{}{
|
|
||||||
"clients": {}, "projects": {}, "recipes": {}, "documents": {}, "archive": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a root or child node.
|
// Create inserts a root or child node.
|
||||||
// parentID may be empty for root-level nodes.
|
// parentID may be empty for root-level nodes.
|
||||||
// For root nodes, section determines sidebar placement (may be empty → inbox).
|
// For root nodes, section determines sidebar placement (may be empty = inbox).
|
||||||
|
// section must be a valid section (clients, projects, etc.) or empty for inbox.
|
||||||
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
|
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
|
||||||
if !IsValidType(typ) {
|
if !IsValidType(typ) {
|
||||||
return nil, fmt.Errorf("invalid node type: %s", typ)
|
return nil, fmt.Errorf("invalid node type: %s", typ)
|
||||||
@@ -67,6 +61,9 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
|
|||||||
if title == "" {
|
if title == "" {
|
||||||
return nil, errors.New("title is required")
|
return nil, errors.New("title is required")
|
||||||
}
|
}
|
||||||
|
if section != "" && !IsValidSection(section) {
|
||||||
|
return nil, fmt.Errorf("invalid section: %s", section)
|
||||||
|
}
|
||||||
|
|
||||||
n := &Node{
|
n := &Node{
|
||||||
ID: util.UUID7(),
|
ID: util.UUID7(),
|
||||||
@@ -87,9 +84,22 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Bump parent's updated_at so it appears in today view.
|
||||||
|
if parentID != "" {
|
||||||
|
_ = r.touch(parentID)
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// touch updates a node's updated_at without changing other fields.
|
||||||
|
func (r *Repository) touch(id string) error {
|
||||||
|
t := now()
|
||||||
|
_, err := r.db.Exec(
|
||||||
|
`UPDATE nodes SET updated_at=?, revision=revision+1
|
||||||
|
WHERE id=? AND deleted_at IS NULL`, t, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) insertNode(n *Node) error {
|
func (r *Repository) insertNode(n *Node) error {
|
||||||
var parent interface{}
|
var parent interface{}
|
||||||
if n.ParentID != nil {
|
if n.ParentID != nil {
|
||||||
@@ -182,6 +192,41 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
|
|||||||
return scanNodes(rows)
|
return scanNodes(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todayBoundaries returns RFC3339 start and end strings for the current day
|
||||||
|
// in UTC, so string comparison against UTC-stored DB timestamps is correct.
|
||||||
|
func todayBoundaries() (string, string) {
|
||||||
|
now := time.Now()
|
||||||
|
y, m, d := now.Date()
|
||||||
|
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
end := start.Add(24 * time.Hour)
|
||||||
|
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTodayNodes returns active root-level nodes created or updated today.
|
||||||
|
// This is a dynamic view, not a section — it shows the day's activity.
|
||||||
|
// Child nodes (notes, files, folders) are not listed directly; instead,
|
||||||
|
// their parent is bumped via touch() on creation.
|
||||||
|
func (r *Repository) ListTodayNodes() ([]Node, error) {
|
||||||
|
start, end := todayBoundaries()
|
||||||
|
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
||||||
|
created_at,updated_at,deleted_at,revision,device_id
|
||||||
|
FROM nodes
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND parent_id IS NULL
|
||||||
|
AND (
|
||||||
|
(created_at >= ? AND created_at < ?)
|
||||||
|
OR
|
||||||
|
(updated_at >= ? AND updated_at < ?)
|
||||||
|
)
|
||||||
|
ORDER BY updated_at DESC, created_at DESC`
|
||||||
|
rows, err := r.db.Query(q, start, end, start, end)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanNodes(rows)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateTitle changes title (slug is recomputed).
|
// UpdateTitle changes title (slug is recomputed).
|
||||||
func (r *Repository) UpdateTitle(id, title string) error {
|
func (r *Repository) UpdateTitle(id, title string) error {
|
||||||
if title == "" {
|
if title == "" {
|
||||||
|
|||||||
@@ -35,12 +35,41 @@ var TypeSet = map[string]struct{}{
|
|||||||
TypeLink: {},
|
TypeLink: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Valid sections for root-level nodes.
|
||||||
|
// today and inbox are service sections, not stored in nodes.section.
|
||||||
|
var validSections = map[string]struct{}{
|
||||||
|
"clients": {},
|
||||||
|
"projects": {},
|
||||||
|
"recipes": {},
|
||||||
|
"documents": {},
|
||||||
|
"archive": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceSections are sidebar entries that are not stored as node sections.
|
||||||
|
var serviceSections = map[string]struct{}{
|
||||||
|
"today": {},
|
||||||
|
"inbox": {},
|
||||||
|
"activity": {},
|
||||||
|
}
|
||||||
|
|
||||||
// IsValidType checks whether a type string is recognized.
|
// IsValidType checks whether a type string is recognized.
|
||||||
func IsValidType(t string) bool {
|
func IsValidType(t string) bool {
|
||||||
_, ok := TypeSet[t]
|
_, ok := TypeSet[t]
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsValidSection returns true for sections that can be stored on a node.
|
||||||
|
func IsValidSection(s string) bool {
|
||||||
|
_, ok := validSections[s]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsServiceSection returns true for sidebar-only sections (today, inbox).
|
||||||
|
func IsServiceSection(s string) bool {
|
||||||
|
_, ok := serviceSections[s]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// Slugify converts a title into a filesystem-safe slug.
|
// Slugify converts a title into a filesystem-safe slug.
|
||||||
// Examples:
|
// Examples:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func setupService(t *testing.T) (*Service, *nodes.Repository, string) {
|
|||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
fileSvc := files.NewService(db, dir)
|
fileSvc := files.NewService(db, dir, nodeRepo)
|
||||||
svc := NewService(db, dir, nodeRepo, fileSvc)
|
svc := NewService(db, dir, nodeRepo, fileSvc)
|
||||||
return svc, nodeRepo, dir
|
return svc, nodeRepo, dir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/files"
|
||||||
|
"verstak/internal/core/notes"
|
||||||
|
"verstak/internal/core/nodes"
|
||||||
|
"verstak/internal/core/search"
|
||||||
|
"verstak/internal/core/storage"
|
||||||
|
"verstak/internal/core/vault"
|
||||||
|
"verstak/internal/core/worklog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openTestDB(t *testing.T) *storage.DB {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMVPSmoke(t *testing.T) {
|
||||||
|
// Create a test vault with a real filesystem area.
|
||||||
|
vaultDir := t.TempDir()
|
||||||
|
|
||||||
|
// 1. Init vault.
|
||||||
|
if err := vault.Init(vaultDir); err != nil {
|
||||||
|
t.Fatalf("vault init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := openTestDB(t)
|
||||||
|
nodeRepo := nodes.NewRepository(db)
|
||||||
|
fileSvc := files.NewService(db, vaultDir, nodeRepo)
|
||||||
|
noteSvc := notes.NewService(db, vaultDir, nodeRepo, fileSvc)
|
||||||
|
actionSvc := actions.NewService(db)
|
||||||
|
worklogSvc := worklog.NewService(db)
|
||||||
|
searchSvc := search.NewService(db)
|
||||||
|
|
||||||
|
// 2. Create client case structure.
|
||||||
|
client, err := nodeRepo.Create("", nodes.TypeCase, "ООО Ромашка", "clients")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create client: %v", err)
|
||||||
|
}
|
||||||
|
project, err := nodeRepo.Create(client.ID, nodes.TypeCase, "Сайт", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create a Markdown note.
|
||||||
|
noteNode, noteRec, err := noteSvc.Create(project.ID, "overview.md", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create note: %v", err)
|
||||||
|
}
|
||||||
|
if noteNode.Title != "overview.md" {
|
||||||
|
t.Errorf("note title = %q, want overview.md", noteNode.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Write note content.
|
||||||
|
content := "# Сайт ООО Ромашка\n\nWordPress/nginx. Иногда обновление."
|
||||||
|
if err := noteSvc.Save(noteNode.ID, content); err != nil {
|
||||||
|
t.Fatalf("save note: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Read note back.
|
||||||
|
got, err := noteSvc.Read(noteNode.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read note: %v", err)
|
||||||
|
}
|
||||||
|
if got != content {
|
||||||
|
t.Errorf("note content mismatch: got %q, want %q", got, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verify note file exists on disk.
|
||||||
|
noteFileRecs, _ := fileSvc.ListByNode(noteNode.ID)
|
||||||
|
notePath := ""
|
||||||
|
if len(noteFileRecs) > 0 {
|
||||||
|
notePath = noteFileRecs[0].Path
|
||||||
|
if _, err := os.Stat(notePath); os.IsNotExist(err) {
|
||||||
|
t.Errorf("note file not on disk: %s", notePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = noteRec
|
||||||
|
|
||||||
|
// 7. Add a dummy external file.
|
||||||
|
dummyPath := filepath.Join(vaultDir, "dogovor.docx")
|
||||||
|
if err := os.WriteFile(dummyPath, []byte("dummy contract data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
created, err := fileSvc.AddPathCopy(project.ID, dummyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add file: %v", err)
|
||||||
|
}
|
||||||
|
if len(created) == 0 {
|
||||||
|
t.Fatal("no nodes created from file import")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Open the imported file (just verify it opens without error).
|
||||||
|
fileNode := created[0]
|
||||||
|
if fileNode.Type != nodes.TypeFile {
|
||||||
|
t.Errorf("expected file type, got %s", fileNode.Type)
|
||||||
|
}
|
||||||
|
fileRecs, err := fileSvc.ListByNode(fileNode.ID)
|
||||||
|
if err != nil || len(fileRecs) == 0 {
|
||||||
|
t.Fatalf("file record not found for imported file")
|
||||||
|
}
|
||||||
|
_ = fileSvc.Open(fileRecs[0].ID) // may fail in test (no display)
|
||||||
|
|
||||||
|
// 9. Verify the imported file node exists as a child of the project.
|
||||||
|
children, err := nodeRepo.ListChildren(project.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list children: %v", err)
|
||||||
|
}
|
||||||
|
hasImported := false
|
||||||
|
for _, c := range children {
|
||||||
|
if c.Type == nodes.TypeFile && c.Title == "dogovor.docx" {
|
||||||
|
hasImported = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasImported {
|
||||||
|
t.Error("imported file node not found under project")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Create actions.
|
||||||
|
urlAction, err := actionSvc.Create(project.ID, actions.KindOpenURL, "Открыть сайт", "", "", "https://example.com", nil, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create url action: %v", err)
|
||||||
|
}
|
||||||
|
if urlAction.Kind != actions.KindOpenURL {
|
||||||
|
t.Errorf("action kind = %s, want %s", urlAction.Kind, actions.KindOpenURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
folderAction, err := actionSvc.Create(project.ID, actions.KindOpenFolder, "Открыть папку", vaultDir, "", "", nil, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create folder action: %v", err)
|
||||||
|
}
|
||||||
|
if folderAction.Kind != actions.KindOpenFolder {
|
||||||
|
t.Errorf("action kind = %s", folderAction.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. List actions.
|
||||||
|
actionList, err := actionSvc.ListByNode(project.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list actions: %v", err)
|
||||||
|
}
|
||||||
|
if len(actionList) != 2 {
|
||||||
|
t.Errorf("expected 2 actions, got %d", len(actionList))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Run an action (open URL — should not error).
|
||||||
|
_, err = actionSvc.Run(urlAction.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run url action: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Add worklog.
|
||||||
|
entry, err := worklogSvc.Add(project.ID, "Обновил витрину сайта, баннеры", "", 180, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add worklog: %v", err)
|
||||||
|
}
|
||||||
|
if entry.Minutes == nil || *entry.Minutes != 180 {
|
||||||
|
t.Errorf("worklog minutes = %v, want 180", entry.Minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. List worklog.
|
||||||
|
worklogList, err := worklogSvc.ListByNode(project.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list worklog: %v", err)
|
||||||
|
}
|
||||||
|
if len(worklogList) != 1 {
|
||||||
|
t.Errorf("expected 1 worklog entry, got %d", len(worklogList))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. Search for something (FTS5 may not be available — gracefully skip).
|
||||||
|
_ = searchSvc.Rebuild()
|
||||||
|
_ = searchSvc.Index(noteNode.ID, noteNode.Title, content, notePath, "", "note")
|
||||||
|
results, err := searchSvc.Search("WordPress")
|
||||||
|
if err != nil {
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
if len(results) > 0 {
|
||||||
|
t.Logf("search found %d results for 'WordPress'", len(results))
|
||||||
|
} else {
|
||||||
|
t.Log("search returned no results (FTS5 may not be available)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. Verify section filtering.
|
||||||
|
roots, err := nodeRepo.ListRoots(false, "clients")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list roots by section: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, r := range roots {
|
||||||
|
if r.ID == client.ID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("client not found in section 'clients'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 17. Soft delete node and verify.
|
||||||
|
if err := nodeRepo.SoftDelete(project.ID); err != nil {
|
||||||
|
t.Fatalf("soft delete project: %v", err)
|
||||||
|
}
|
||||||
|
_, err = nodeRepo.GetActive(project.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error getting deleted node")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 18. Reopen app (simulate by reconnecting to same DB).
|
||||||
|
db2, err := storage.Open(db.Path())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen db: %v", err)
|
||||||
|
}
|
||||||
|
defer db2.Close()
|
||||||
|
repo2 := nodes.NewRepository(db2)
|
||||||
|
|
||||||
|
// 19. Verify data persists.
|
||||||
|
client2, err := repo2.GetActive(client.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get client after reopen: %v", err)
|
||||||
|
}
|
||||||
|
if client2.Title != "ООО Ромашка" {
|
||||||
|
t.Errorf("client title after reopen = %q", client2.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20. Verify soft-deleted node stays soft-deleted.
|
||||||
|
allNodes, err := repo2.ListChildren(client.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list children after reopen: %v", err)
|
||||||
|
}
|
||||||
|
if len(allNodes) == 0 {
|
||||||
|
t.Error("no children found after reopen")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("MVP smoke test passed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMVPSmoke_WorklogReport(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
nodeRepo := nodes.NewRepository(db)
|
||||||
|
worklogSvc := worklog.NewService(db)
|
||||||
|
|
||||||
|
n, err := nodeRepo.Create("", nodes.TypeCase, "Test", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = worklogSvc.Add(n.ID, "Task 1", "", 60, true, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = worklogSvc.Add(n.ID, "Task 2", "", 30, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
report, err := worklogSvc.Report(n.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("report: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") {
|
||||||
|
t.Error("report missing entries")
|
||||||
|
}
|
||||||
|
if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") {
|
||||||
|
t.Error("report missing tasks")
|
||||||
|
}
|
||||||
|
t.Logf("report:\n%s", report)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
// migration008 — activity_events table for the Сегодня (Today) dashboard.
|
||||||
|
const migration008 = `
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
node_id TEXT NOT NULL,
|
||||||
|
parent_id TEXT,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ae_created ON activity_events(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ae_parent ON activity_events(parent_id);
|
||||||
|
`
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
// migration009 — add target_type, target_id, target_path to activity_events.
|
||||||
|
const migration009 = `
|
||||||
|
ALTER TABLE activity_events ADD COLUMN target_type TEXT;
|
||||||
|
ALTER TABLE activity_events ADD COLUMN target_id TEXT;
|
||||||
|
ALTER TABLE activity_events ADD COLUMN target_path TEXT;
|
||||||
|
`
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
// migration010 — sync_ops table for recording operations to push to sync server.
|
||||||
|
const migration010 = `
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_ops (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
op_id TEXT NOT NULL UNIQUE,
|
||||||
|
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 DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
pushed_at TEXT,
|
||||||
|
applied_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_ops_pushed ON sync_ops(pushed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_ops_entity ON sync_ops(entity_type, entity_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_state (
|
||||||
|
device_id TEXT PRIMARY KEY,
|
||||||
|
server_url TEXT NOT NULL DEFAULT '',
|
||||||
|
api_key TEXT NOT NULL DEFAULT '',
|
||||||
|
last_push_rev INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_pull_rev INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_sync_at TEXT
|
||||||
|
);
|
||||||
|
`
|
||||||
@@ -64,7 +64,9 @@ var migrationFiles = map[int]string{
|
|||||||
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, etc.
|
8: migration008,
|
||||||
|
9: migration009,
|
||||||
|
10: migration010,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlobDir returns the path to .verstak/blobs/ inside the vault.
|
||||||
|
func BlobDir(vaultRoot string) string {
|
||||||
|
return filepath.Join(vaultRoot, ".verstak", "blobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlobPath returns the on-disk path for a SHA-256 hash.
|
||||||
|
func BlobPath(blobsDir, shaHex string) string {
|
||||||
|
return filepath.Join(blobsDir, shaHex[:2], shaHex[2:4], shaHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashFile computes SHA-256 of a file.
|
||||||
|
func HashFile(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashBytes computes SHA-256 of byte data.
|
||||||
|
func HashBytes(data []byte) string {
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreBlob copies a file into the blob store, returns its SHA-256.
|
||||||
|
func StoreBlob(blobsDir, srcPath string) (string, error) {
|
||||||
|
shaHex, err := HashFile(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dest := BlobPath(blobsDir, shaHex)
|
||||||
|
if _, err := os.Stat(dest); err == nil {
|
||||||
|
return shaHex, nil // already exists
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0750); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
src, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dst, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = dst.Sync()
|
||||||
|
return shaHex, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBlob reads a blob by SHA-256 hash.
|
||||||
|
func ReadBlob(blobsDir, shaHex string) ([]byte, error) {
|
||||||
|
return os.ReadFile(BlobPath(blobsDir, shaHex))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the package compiles without unused errors.
|
||||||
|
var _ = fmt.Sprintf
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client communicates with the Verstak Sync Server.
|
||||||
|
type Client struct {
|
||||||
|
ServerURL string
|
||||||
|
APIKey string
|
||||||
|
DeviceID string
|
||||||
|
VaultRoot string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a sync client.
|
||||||
|
func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
|
||||||
|
return &Client{
|
||||||
|
ServerURL: serverURL,
|
||||||
|
APIKey: apiKey,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
VaultRoot: vaultRoot,
|
||||||
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterDevice calls POST /api/v1/device/register and returns the API key.
|
||||||
|
func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
|
||||||
|
body := map[string]string{"name": name}
|
||||||
|
var resp struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
if err := c.post("/api/v1/device/register", body, &resp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return resp.APIKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterDeviceWithAuth registers a device with user credentials.
|
||||||
|
func (c *Client) RegisterDeviceWithAuth(name, username, password string) (deviceID, apiKey string, err error) {
|
||||||
|
body := map[string]string{"name": name, "username": username, "password": password}
|
||||||
|
var resp struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
// Temporarily clear API key for this request (server expects login/password, not API key).
|
||||||
|
savedKey := c.APIKey
|
||||||
|
c.APIKey = ""
|
||||||
|
err = c.post("/api/v1/device/register", body, &resp)
|
||||||
|
c.APIKey = savedKey
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return resp.DeviceID, resp.APIKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates with user credentials and returns a session token.
|
||||||
|
func (c *Client) Login(username, password string) (token string, err error) {
|
||||||
|
body := map[string]string{"username": username, "password": password}
|
||||||
|
var resp struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
savedKey := c.APIKey
|
||||||
|
c.APIKey = ""
|
||||||
|
err = c.post("/api/v1/auth/login", body, &resp)
|
||||||
|
c.APIKey = savedKey
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return resp.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushRequest is the payload for POST /sync/push.
|
||||||
|
type PushRequest struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Ops []PushOp `json:"ops"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushOp is a single operation in a push request.
|
||||||
|
type PushOp 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"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushResponse is the response from POST /sync/push.
|
||||||
|
type PushResponse struct {
|
||||||
|
Accepted []string `json:"accepted"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push sends local operations to the server.
|
||||||
|
func (c *Client) Push(ops []Op) (*PushResponse, error) {
|
||||||
|
pushOps := make([]PushOp, len(ops))
|
||||||
|
for i, op := range ops {
|
||||||
|
pushOps[i] = PushOp{
|
||||||
|
OpID: op.OpID,
|
||||||
|
EntityType: op.EntityType,
|
||||||
|
EntityID: op.EntityID,
|
||||||
|
OpType: op.OpType,
|
||||||
|
PayloadJSON: op.PayloadJSON,
|
||||||
|
CreatedAt: op.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := PushRequest{DeviceID: c.DeviceID, Ops: pushOps}
|
||||||
|
var resp PushResponse
|
||||||
|
if err := c.post("/api/v1/sync/push", req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullRequest is the payload for POST /sync/pull.
|
||||||
|
type PullRequest struct {
|
||||||
|
SinceRevision int `json:"since_revision"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullResponse is the response from POST /sync/pull.
|
||||||
|
type PullResponse struct {
|
||||||
|
ServerRevision int `json:"server_revision"`
|
||||||
|
Ops []Op `json:"ops"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull fetches remote operations since a given revision.
|
||||||
|
func (c *Client) Pull(sinceRevision int) (*PullResponse, error) {
|
||||||
|
req := PullRequest{SinceRevision: sinceRevision}
|
||||||
|
var resp PullResponse
|
||||||
|
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadBlob uploads a file to the server and returns its SHA-256.
|
||||||
|
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&b)
|
||||||
|
fw, err := w.CreateFormFile("file", filepath.Base(localPath))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
f, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := io.Copy(fw, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.SHA256, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadBlob downloads a blob by SHA-256 hash.
|
||||||
|
func (c *Client) DownloadBlob(sha256, destPath string) error {
|
||||||
|
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internal ---
|
||||||
|
|
||||||
|
func (c *Client) post(path string, body, result interface{}) error {
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := json.NewEncoder(&b).Encode(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("http: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("server %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
return json.NewDecoder(resp.Body).Decode(result)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"verstak/internal/core/storage"
|
||||||
|
"verstak/internal/core/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entity types (matches activity targets).
|
||||||
|
const (
|
||||||
|
EntityNode = "node"
|
||||||
|
EntityNote = "note"
|
||||||
|
EntityFile = "file"
|
||||||
|
EntityFolder = "folder"
|
||||||
|
EntityAction = "action"
|
||||||
|
EntityWorklog = "worklog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Op types.
|
||||||
|
const (
|
||||||
|
OpCreate = "create"
|
||||||
|
OpUpdate = "update"
|
||||||
|
OpDelete = "delete"
|
||||||
|
OpMove = "move"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Op represents a sync operation.
|
||||||
|
type Op struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OpID string `json:"op_id"`
|
||||||
|
DeviceID string `json:"device_id,omitempty"`
|
||||||
|
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"`
|
||||||
|
PushedAt *string `json:"pushed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service records and manages sync operations.
|
||||||
|
type Service struct {
|
||||||
|
db *storage.DB
|
||||||
|
deviceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a sync service.
|
||||||
|
func NewService(db *storage.DB, deviceID string) *Service {
|
||||||
|
return &Service{db: db, deviceID: deviceID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordOp writes a sync operation to the local sync_ops table.
|
||||||
|
func (s *Service) RecordOp(entityType, entityID, opType string, payload interface{}) error {
|
||||||
|
id := util.UUID7()
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
var payloadStr string
|
||||||
|
if payload != nil {
|
||||||
|
b, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payloadStr = string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, id, s.deviceID, entityType, entityID, opType, payloadStr, now,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnpushedOps returns ops that have not been pushed yet.
|
||||||
|
func (s *Service) GetUnpushedOps() ([]Op, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at
|
||||||
|
FROM sync_ops WHERE pushed_at IS NULL ORDER BY created_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanOps(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPushed marks ops as pushed to server.
|
||||||
|
func (s *Service) MarkPushed(opIDs []string) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
for _, id := range opIDs {
|
||||||
|
_, err := s.db.Exec("UPDATE sync_ops SET pushed_at=? WHERE op_id=?", now, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkApplied marks remote ops as applied locally.
|
||||||
|
func (s *Service) MarkApplied(opIDs []string) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
for _, id := range opIDs {
|
||||||
|
_, err := s.db.Exec("UPDATE sync_ops SET applied_at=? WHERE op_id=?", now, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState returns the current sync state.
|
||||||
|
func (s *Service) GetState() (serverURL, apiKey string, lastPushRev int, lastSyncAt string, err error) {
|
||||||
|
err = s.db.QueryRow(
|
||||||
|
`SELECT server_url, api_key, last_push_rev, COALESCE(last_sync_at,'') FROM sync_state WHERE device_id=?`,
|
||||||
|
s.deviceID).Scan(&serverURL, &apiKey, &lastPushRev, &lastSyncAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", "", 0, "", nil
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetState saves sync connection state.
|
||||||
|
func (s *Service) SetState(serverURL, apiKey string) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO sync_state (device_id, server_url, api_key, last_push_rev, last_sync_at)
|
||||||
|
VALUES (?, ?, ?, 0, '')
|
||||||
|
ON CONFLICT(device_id) DO UPDATE SET server_url=excluded.server_url, api_key=excluded.api_key`,
|
||||||
|
s.deviceID, serverURL, apiKey,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func scanOps(rows *sql.Rows) ([]Op, error) {
|
||||||
|
var out []Op
|
||||||
|
for rows.Next() {
|
||||||
|
var o Op
|
||||||
|
var pushedAt sql.NullString
|
||||||
|
if err := rows.Scan(&o.ID, &o.OpID, &o.DeviceID, &o.EntityType, &o.EntityID,
|
||||||
|
&o.OpType, &o.PayloadJSON, &o.CreatedAt, &pushedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pushedAt.Valid {
|
||||||
|
o.PushedAt = &pushedAt.String
|
||||||
|
}
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustVar ensures the package is not considered unused.
|
||||||
|
var _ = fmt.Sprintf
|
||||||
@@ -38,7 +38,7 @@ type Server struct {
|
|||||||
// NewServer creates a GUI server for the given vault.
|
// NewServer creates a GUI server for the given vault.
|
||||||
func NewServer(db *storage.DB, vaultRoot string) *Server {
|
func NewServer(db *storage.DB, vaultRoot string) *Server {
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
fileSvc := files.NewService(db, vaultRoot)
|
fileSvc := files.NewService(db, vaultRoot, nodeRepo)
|
||||||
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
||||||
actionSvc := actions.NewService(db)
|
actionSvc := actions.NewService(db)
|
||||||
workSvc := worklog.NewService(db)
|
workSvc := worklog.NewService(db)
|
||||||
|
|||||||
Executable
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== Verstak Sync E2E Smoke Test ==="
|
||||||
|
|
||||||
|
SERVER_PORT=18999
|
||||||
|
SDIR=$(mktemp -d)
|
||||||
|
VD=$(mktemp -d)
|
||||||
|
cleanup() { kill "$SPID" 2>/dev/null || true; rm -rf "$SDIR" "$VD"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo ":: Build"
|
||||||
|
go build -o /tmp/vs-server ./cmd/verstak-server/
|
||||||
|
go build -o /tmp/vs-cli ./cmd/verstak/
|
||||||
|
|
||||||
|
echo ":: Start server"
|
||||||
|
/tmp/vs-server --port "$SERVER_PORT" --data "$SDIR" --admin-user admin --admin-pass pass > /dev/null 2>&1 &
|
||||||
|
SPID=$!
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo ":: Health"
|
||||||
|
curl -sf "http://localhost:$SERVER_PORT/api/v1/health" | grep -q '"ok"' && echo " OK"
|
||||||
|
|
||||||
|
echo ":: Register device"
|
||||||
|
# Register user first
|
||||||
|
curl -sf -X POST "http://localhost:$SERVER_PORT/api/v1/auth/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"smokeuser","email":"smoke@test.local","password":"mypass123"}' > /dev/null
|
||||||
|
# Confirm email
|
||||||
|
TOKEN=$(sqlite3 "$SDIR/server.db" "SELECT token FROM server_email_tokens WHERE purpose='confirm' LIMIT 1")
|
||||||
|
curl -sf "http://localhost:$SERVER_PORT/api/v1/auth/confirm?token=$TOKEN" > /dev/null
|
||||||
|
# Register device with user creds
|
||||||
|
REG=$(curl -sf -X POST "http://localhost:$SERVER_PORT/api/v1/device/register" \
|
||||||
|
-H "Content-Type: application/json" -d '{"name":"smoke","username":"smokeuser","password":"mypass123"}')
|
||||||
|
DID=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['device_id'])")
|
||||||
|
AKEY=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['api_key'])")
|
||||||
|
echo " device=$DID"
|
||||||
|
|
||||||
|
echo ":: Init vault"
|
||||||
|
/tmp/vs-cli init --vault "$VD"
|
||||||
|
|
||||||
|
echo ":: Configure sync"
|
||||||
|
cat > "$VD/.verstak/config.yml" <<YML
|
||||||
|
sync:
|
||||||
|
server_url: http://localhost:$SERVER_PORT
|
||||||
|
api_key: $AKEY
|
||||||
|
device_id: $DID
|
||||||
|
auto_sync: false
|
||||||
|
YML
|
||||||
|
|
||||||
|
echo ":: Insert sync op (simulate recorded op)"
|
||||||
|
sqlite3 "$VD/.verstak/index.db" \
|
||||||
|
"INSERT INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at) VALUES ('e1','e1','$DID','node','n1','create','{\"title\":\"t\"}',datetime('now'));"
|
||||||
|
|
||||||
|
echo ":: Push"
|
||||||
|
/tmp/vs-cli sync push --vault "$VD" | grep -q "Pushed 1" && echo " OK"
|
||||||
|
|
||||||
|
echo ":: Pull"
|
||||||
|
/tmp/vs-cli sync pull --vault "$VD" | grep -q "Pulled" && echo " OK"
|
||||||
|
|
||||||
|
echo ":: Status"
|
||||||
|
/tmp/vs-cli sync status --vault "$VD"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== ALL TESTS PASSED ==="
|
||||||
Reference in New Issue
Block a user