Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edc708a106 | ||
|
|
ee708d30bb | ||
|
|
305158ecc6 | ||
|
|
996322f3a9 | ||
|
|
a098cf721c | ||
|
|
3672e3133b | ||
|
|
5a1c4c6d7f | ||
|
|
08c9d5dbea | ||
|
|
c74fa3ad43 | ||
|
|
69891e395c | ||
|
|
a4ae22c445 |
+6
-2
@@ -22,8 +22,12 @@ go.work
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/bindings/
|
frontend/bindings/
|
||||||
verstak-gui
|
/verstak-gui
|
||||||
verstak-cli
|
/verstak-cli
|
||||||
|
|
||||||
|
# Vault data
|
||||||
|
.verstak/
|
||||||
|
spaces/
|
||||||
|
|
||||||
# VS Code
|
# VS Code
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
Binary file not shown.
+392
-3
@@ -6,14 +6,18 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"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/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/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
@@ -28,9 +32,11 @@ 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
|
search *search.Service
|
||||||
|
plugins *plugins.Manager
|
||||||
vault string
|
vault string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +121,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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -123,6 +170,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: "Рецепты"},
|
||||||
@@ -143,6 +191,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 {
|
||||||
@@ -161,10 +378,14 @@ 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, "")
|
||||||
dto := toNodeDTO(n)
|
dto := toNodeDTO(n)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
@@ -173,6 +394,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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -198,6 +479,7 @@ 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, "")
|
||||||
dto := toNodeDTO(node)
|
dto := toNodeDTO(node)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
@@ -209,7 +491,18 @@ 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, "")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -278,6 +571,9 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
for _, n := range nodes {
|
||||||
|
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||||
|
}
|
||||||
return toNodeDTOs(nodes), nil
|
return toNodeDTOs(nodes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,10 +582,27 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
for _, n := range nodes {
|
||||||
|
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||||
|
}
|
||||||
return toNodeDTOs(nodes), nil
|
return toNodeDTOs(nodes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DeleteFileOrFolder(nodeID string) error {
|
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, "")
|
||||||
|
}
|
||||||
return a.files.DeleteNodeAndChildren(nodeID)
|
return a.files.DeleteNodeAndChildren(nodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +611,7 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
|
||||||
dto := toNodeDTO(node)
|
dto := toNodeDTO(node)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
@@ -307,16 +621,73 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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, "")
|
||||||
dto := toNodeDTO(node)
|
dto := toNodeDTO(node)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) RenameNode(nodeID, newTitle string) error {
|
func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||||
return a.nodes.UpdateTitle(nodeID, newTitle)
|
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+`"}`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ValidateName(name string) error {
|
||||||
|
return files.ValidateName(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) MoveNode(nodeID, newParentID string) error {
|
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||||
return a.nodes.Move(nodeID, newParentID, 0)
|
// 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+`"}`)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
||||||
@@ -349,6 +720,24 @@ 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
|
||||||
|
}
|
||||||
|
return &ActionDTO{
|
||||||
|
ID: rec.ID,
|
||||||
|
NodeID: rec.NodeID,
|
||||||
|
Title: rec.Title,
|
||||||
|
Type: rec.Kind,
|
||||||
|
Data: data,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteAction(id string) error {
|
||||||
|
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
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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/main-a-M2pafQ.js"></script>
|
<script type="module" crossorigin src="/assets/main-DZkGJWBF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-BnVt-oqm.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+14
-9
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/activity"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
@@ -46,19 +47,23 @@ func main() {
|
|||||||
fileSvc := files.NewService(db, abs, nodeRepo)
|
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)
|
||||||
|
pm.Discover()
|
||||||
|
|
||||||
app := &App{
|
app := &App{
|
||||||
db: db,
|
db: db,
|
||||||
nodes: nodeRepo,
|
nodes: nodeRepo,
|
||||||
files: fileSvc,
|
files: fileSvc,
|
||||||
notes: noteSvc,
|
notes: noteSvc,
|
||||||
actions: actionSvc,
|
activity: activitySvc,
|
||||||
worklog: worklogSvc,
|
actions: actionSvc,
|
||||||
search: searchSvc,
|
worklog: worklogSvc,
|
||||||
vault: abs,
|
search: searchSvc,
|
||||||
|
plugins: pm,
|
||||||
|
vault: abs,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = wails.Run(&options.App{
|
err = wails.Run(&options.App{
|
||||||
|
|||||||
@@ -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 (плагин).
|
||||||
|
|
||||||
|
|||||||
+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
|
|
||||||
|
|||||||
+516
-39
@@ -26,6 +26,12 @@
|
|||||||
// ===== State =====
|
// ===== State =====
|
||||||
let sections = []
|
let sections = []
|
||||||
let nodes = []
|
let nodes = []
|
||||||
|
let todayDashboard = null
|
||||||
|
let activityFeed = []
|
||||||
|
let activityOffset = 0
|
||||||
|
let activityHasMore = true
|
||||||
|
let activityLoading = false
|
||||||
|
let caseActivity = []
|
||||||
let version = ''
|
let version = ''
|
||||||
let error = ''
|
let error = ''
|
||||||
let selectedSection = ''
|
let selectedSection = ''
|
||||||
@@ -41,8 +47,23 @@
|
|||||||
let showCreateNode = false
|
let showCreateNode = false
|
||||||
let newNodeTitle = ''
|
let newNodeTitle = ''
|
||||||
let newNodeSection = 'clients'
|
let newNodeSection = 'clients'
|
||||||
|
let newNodeTemplate = ''
|
||||||
|
let templates = []
|
||||||
let showCreateNote = false
|
let showCreateNote = false
|
||||||
let newNoteTitle = ''
|
let newNoteTitle = ''
|
||||||
|
let showCreateAction = false
|
||||||
|
let newActionTitle = ''
|
||||||
|
let newActionKind = 'open_url'
|
||||||
|
let newActionData = ''
|
||||||
|
let actionKinds = [
|
||||||
|
{ id: 'open_url', label: 'Открыть URL' },
|
||||||
|
{ id: 'open_file', label: 'Открыть файл' },
|
||||||
|
{ id: 'open_folder', label: 'Открыть папку' },
|
||||||
|
{ id: 'run_command', label: 'Запустить команду' },
|
||||||
|
{ id: 'run_script', label: 'Запустить скрипт' },
|
||||||
|
{ id: 'open_terminal', label: 'Открыть терминал' },
|
||||||
|
{ id: 'launch_app', label: 'Запустить приложение' },
|
||||||
|
]
|
||||||
let loading = true
|
let loading = true
|
||||||
let importing = false
|
let importing = false
|
||||||
let importSummary = null
|
let importSummary = null
|
||||||
@@ -72,6 +93,11 @@
|
|||||||
let confirmAction = null
|
let confirmAction = null
|
||||||
let cancelAction = null
|
let cancelAction = null
|
||||||
|
|
||||||
|
let showRename = false
|
||||||
|
let renameId = ''
|
||||||
|
let renameValue = ''
|
||||||
|
let renameError = ''
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'overview', label: 'Обзор' },
|
{ id: 'overview', label: 'Обзор' },
|
||||||
{ id: 'notes', label: 'Заметки' },
|
{ id: 'notes', label: 'Заметки' },
|
||||||
@@ -94,6 +120,7 @@
|
|||||||
sections = [
|
sections = [
|
||||||
{ 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: 'Рецепты' },
|
||||||
@@ -129,11 +156,26 @@
|
|||||||
worklog = []
|
worklog = []
|
||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
error = ''
|
error = ''
|
||||||
|
todayDashboard = null
|
||||||
|
activityFeed = []
|
||||||
|
activityOffset = 0
|
||||||
|
activityHasMore = true
|
||||||
|
nodes = []
|
||||||
try {
|
try {
|
||||||
nodes = await wailsCall('ListNodesBySection', id) || []
|
if (id === 'today') {
|
||||||
|
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||||||
|
} else if (id === 'activity') {
|
||||||
|
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
|
||||||
|
activityOffset = activityFeed.length
|
||||||
|
activityHasMore = activityFeed.length === 50
|
||||||
|
} else {
|
||||||
|
nodes = await wailsCall('ListNodesBySection', id) || []
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e)
|
error = String(e)
|
||||||
nodes = []
|
nodes = []
|
||||||
|
todayDashboard = { cases: [] }
|
||||||
|
activityFeed = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +200,7 @@
|
|||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
showCreateNote = false
|
showCreateNote = false
|
||||||
error = ''
|
error = ''
|
||||||
|
caseActivity = []
|
||||||
await loadTabData(node.id)
|
await loadTabData(node.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +209,7 @@
|
|||||||
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
||||||
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
|
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
|
||||||
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
|
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
|
||||||
|
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTree(nodeID) {
|
async function loadTree(nodeID) {
|
||||||
@@ -282,19 +326,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renameItem(id) {
|
function renameItem(id) {
|
||||||
const name = prompt('Новое имя:')
|
const item = fileItems.find(x => x.id === id)
|
||||||
if (!name || !name.trim()) return
|
if (item) openRename(item.id, item.name)
|
||||||
renameSubmit(id, name.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renameSubmit(id, newName) {
|
|
||||||
try {
|
|
||||||
await wailsCall('RenameNode', id, newName)
|
|
||||||
const parentId = currentFolderId || selectedNode.id
|
|
||||||
await loadFolder(parentId)
|
|
||||||
} catch (e) {
|
|
||||||
error = String(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cutItem(id) {
|
function cutItem(id) {
|
||||||
@@ -343,6 +376,20 @@
|
|||||||
selectedIds = fileItems.map(x => x.id)
|
selectedIds = fileItems.map(x => x.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rangeSelect(id) {
|
||||||
|
if (fileItems.length === 0) return
|
||||||
|
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
|
||||||
|
const lastIdx = fileItems.findIndex(x => x.id === lastId)
|
||||||
|
const curIdx = fileItems.findIndex(x => x.id === id)
|
||||||
|
if (lastIdx === -1 || curIdx === -1) return
|
||||||
|
const start = Math.min(lastIdx, curIdx)
|
||||||
|
const end = Math.max(lastIdx, curIdx)
|
||||||
|
const range = fileItems.slice(start, end + 1).map(x => x.id)
|
||||||
|
const set = new Set(selectedIds)
|
||||||
|
range.forEach(x => set.add(x))
|
||||||
|
selectedIds = [...set]
|
||||||
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selectedIds = []
|
selectedIds = []
|
||||||
}
|
}
|
||||||
@@ -421,15 +468,106 @@
|
|||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
if (e.key === 'c') { e.preventDefault(); copySelected() }
|
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
|
||||||
else if (e.key === 'x') { e.preventDefault(); cutSelected() }
|
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
|
||||||
else if (e.key === 'v') { e.preventDefault(); pasteItem() }
|
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
|
||||||
else if (e.key === 'a') { e.preventDefault(); selectAll() }
|
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
|
||||||
|
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
|
||||||
|
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
openSelected()
|
||||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected() }
|
if (previewItem) { e.preventDefault(); closePreview(); return }
|
||||||
|
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
||||||
|
// Backspace without selection → navigate up
|
||||||
|
if (e.key === 'Backspace' && folderStack.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
navigateBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
if (previewItem) { closePreview(); return }
|
||||||
|
if (selectedIds.length > 0) { clearSelection(); return }
|
||||||
|
} else if (e.key === 'F2') {
|
||||||
|
e.preventDefault()
|
||||||
|
openRenameForSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSelected() {
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||||
|
if (item) {
|
||||||
|
if (item.type === 'folder') {
|
||||||
|
navigateToFolder(item.id)
|
||||||
|
} else {
|
||||||
|
openPreview(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSelectedExternal() {
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||||
|
if (item && item.fileId) {
|
||||||
|
wailsCall('OpenFile', item.fileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Rename modal =====
|
||||||
|
|
||||||
|
function openRename(id, currentName) {
|
||||||
|
renameId = id
|
||||||
|
renameValue = currentName
|
||||||
|
renameError = ''
|
||||||
|
showRename = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRenameForSelection() {
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||||
|
if (item) {
|
||||||
|
openRename(item.id, item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRename() {
|
||||||
|
const name = renameValue.trim()
|
||||||
|
if (!name) { renameError = 'Имя не может быть пустым'; return }
|
||||||
|
// Validate name via backend
|
||||||
|
try {
|
||||||
|
await wailsCall('ValidateName', name)
|
||||||
|
} catch (e) {
|
||||||
|
renameError = 'Недопустимое имя'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showRename = false
|
||||||
|
renameId = ''
|
||||||
|
try {
|
||||||
|
await wailsCall('RenameNode', renameId, name)
|
||||||
|
const parentId = currentFolderId || selectedNode.id
|
||||||
|
await loadFolder(parentId)
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
showRename = false
|
||||||
|
renameId = ''
|
||||||
|
renameValue = ''
|
||||||
|
renameError = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRenameKeydown(e) {
|
||||||
|
if (e.key === 'Enter') submitRename()
|
||||||
|
else renameError = ''
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Confirm modal =====
|
// ===== Confirm modal =====
|
||||||
|
|
||||||
function openConfirm(opts) {
|
function openConfirm(opts) {
|
||||||
@@ -463,14 +601,22 @@
|
|||||||
showCreateNode = true
|
showCreateNode = true
|
||||||
newNodeTitle = ''
|
newNodeTitle = ''
|
||||||
newNodeSection = selectedSection || 'clients'
|
newNodeSection = selectedSection || 'clients'
|
||||||
|
newNodeTemplate = ''
|
||||||
|
wailsCall('ListTemplates').then(t => { templates = t || [] }).catch(() => { templates = [] })
|
||||||
}
|
}
|
||||||
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
|
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
|
||||||
async function submitCreateNode() {
|
async function submitCreateNode() {
|
||||||
if (!newNodeTitle.trim()) return
|
if (!newNodeTitle.trim()) return
|
||||||
try {
|
try {
|
||||||
const node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
|
let node
|
||||||
|
if (newNodeTemplate) {
|
||||||
|
node = await wailsCall('FromTemplate', '', 'case', newNodeTitle.trim(), newNodeSection, newNodeTemplate)
|
||||||
|
} else {
|
||||||
|
node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
|
||||||
|
}
|
||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
newNodeTitle = ''
|
newNodeTitle = ''
|
||||||
|
newNodeTemplate = ''
|
||||||
await selectSection(newNodeSection)
|
await selectSection(newNodeSection)
|
||||||
} catch (e) { error = String(e) }
|
} catch (e) { error = String(e) }
|
||||||
}
|
}
|
||||||
@@ -652,10 +798,90 @@
|
|||||||
|
|
||||||
// ===== Helpers =====
|
// ===== Helpers =====
|
||||||
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
|
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
|
||||||
|
function eventLabel(type) {
|
||||||
|
const labels = {
|
||||||
|
'note_created': 'Заметка создана',
|
||||||
|
'note_updated': 'Заметка изменена',
|
||||||
|
'file_added': 'Файл добавлен',
|
||||||
|
'file_deleted': 'Файл удалён',
|
||||||
|
'file_renamed': 'Файл переименован',
|
||||||
|
'file_copied': 'Файл скопирован',
|
||||||
|
'file_moved': 'Файл перемещён',
|
||||||
|
'folder_added': 'Папка добавлена',
|
||||||
|
'folder_deleted': 'Папка удалена',
|
||||||
|
'folder_renamed': 'Папка переименована',
|
||||||
|
'node_created': 'Дело создано',
|
||||||
|
'node_updated': 'Дело изменено',
|
||||||
|
}
|
||||||
|
return labels[type] || type
|
||||||
|
}
|
||||||
|
function eventIcon(type) {
|
||||||
|
if (type === 'note_created' || type === 'file_added' || type === 'folder_added' || type === 'node_created') return '+'
|
||||||
|
if (type === 'file_deleted' || type === 'folder_deleted') return '×'
|
||||||
|
if (type === 'file_renamed' || type === 'folder_renamed' || type === 'note_updated' || type === 'node_updated') return '~'
|
||||||
|
if (type === 'file_copied') return '⧉'
|
||||||
|
if (type === 'file_moved') return '→'
|
||||||
|
return '•'
|
||||||
|
}
|
||||||
|
function formatTime(str) {
|
||||||
|
if (!str) return ''
|
||||||
|
try { return new Date(str).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' }
|
||||||
|
}
|
||||||
function formatDate(str) {
|
function formatDate(str) {
|
||||||
if (!str) return ''
|
if (!str) return ''
|
||||||
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
|
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
|
||||||
}
|
}
|
||||||
|
function nodeKindLabel(kind) {
|
||||||
|
const labels = { 'project': 'Проект', 'client': 'Клиент', 'document': 'Документ', 'recipe': 'Рецепт', 'archive': 'Архив', 'case': 'Дело' }
|
||||||
|
return labels[kind] || kind || 'Дело'
|
||||||
|
}
|
||||||
|
function pluralize(n, one, few, many) {
|
||||||
|
n = Math.abs(n) % 100
|
||||||
|
if (n >= 5 && n <= 20) return many
|
||||||
|
n %= 10
|
||||||
|
if (n === 1) return one
|
||||||
|
if (n >= 2 && n <= 4) return few
|
||||||
|
return many
|
||||||
|
}
|
||||||
|
// ===== Actions =====
|
||||||
|
function openCreateAction() {
|
||||||
|
showCreateAction = true
|
||||||
|
newActionTitle = ''
|
||||||
|
newActionKind = 'open_url'
|
||||||
|
newActionData = ''
|
||||||
|
}
|
||||||
|
function cancelCreateAction() { showCreateAction = false; newActionTitle = ''; newActionData = '' }
|
||||||
|
async function submitCreateAction() {
|
||||||
|
if (!newActionTitle.trim() || !newActionData.trim() || !selectedNode) return
|
||||||
|
try {
|
||||||
|
const action = await wailsCall('CreateAction', selectedNode.id, newActionKind, newActionTitle.trim(), newActionData.trim())
|
||||||
|
if (action && action.id) {
|
||||||
|
actions = [...actions, action]
|
||||||
|
}
|
||||||
|
showCreateAction = false
|
||||||
|
newActionTitle = ''
|
||||||
|
newActionData = ''
|
||||||
|
} catch (e) { error = String(e) }
|
||||||
|
}
|
||||||
|
async function deleteAction(id) {
|
||||||
|
try {
|
||||||
|
await wailsCall('DeleteAction', id)
|
||||||
|
actions = actions.filter(a => a.id !== id)
|
||||||
|
} catch (e) { error = String(e) }
|
||||||
|
}
|
||||||
|
function actionKindLabel(kind) {
|
||||||
|
const k = actionKinds.find(k => k.id === kind)
|
||||||
|
return k ? k.label : kind
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNodeById(id) {
|
||||||
|
try {
|
||||||
|
const node = await wailsCall('GetNodeDetail', id)
|
||||||
|
if (node) selectNode(node)
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
@@ -675,7 +901,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if selectedSection}
|
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
|
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
|
||||||
{#each nodes as node}
|
{#each nodes as node}
|
||||||
@@ -757,7 +983,7 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
Добавить файл
|
Добавить файл
|
||||||
</button>
|
</button>
|
||||||
<button class="qa-btn" disabled title="Следующий этап">
|
<button class="qa-btn" on:click={openCreateAction}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||||
Добавить действие
|
Добавить действие
|
||||||
</button>
|
</button>
|
||||||
@@ -832,7 +1058,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if folderStack.length > 0}
|
{#if folderStack.length > 0}
|
||||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => {
|
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => {
|
||||||
const i = e.detail
|
const i = e.detail
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
folderStack = []
|
folderStack = []
|
||||||
@@ -847,7 +1073,7 @@
|
|||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/>
|
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if fileItems.length === 0}
|
{#if fileItems.length === 0}
|
||||||
@@ -877,6 +1103,7 @@
|
|||||||
on:navigate={(e) => navigateToFolder(e.detail)}
|
on:navigate={(e) => navigateToFolder(e.detail)}
|
||||||
on:preview={(e) => openPreview(e.detail)}
|
on:preview={(e) => openPreview(e.detail)}
|
||||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||||
|
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
|
||||||
on:delete={(e) => deleteFile(e.detail)}
|
on:delete={(e) => deleteFile(e.detail)}
|
||||||
on:rename={(e) => renameItem(e.detail.id)}
|
on:rename={(e) => renameItem(e.detail.id)}
|
||||||
on:duplicate={(e) => duplicateItem(e.detail)}
|
on:duplicate={(e) => duplicateItem(e.detail)}
|
||||||
@@ -884,6 +1111,7 @@
|
|||||||
on:copy={(e) => copyItem(e.detail)}
|
on:copy={(e) => copyItem(e.detail)}
|
||||||
on:selectOne={(e) => selectOne(e.detail)}
|
on:selectOne={(e) => selectOne(e.detail)}
|
||||||
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
||||||
|
on:rangeSelect={(e) => rangeSelect(e.detail)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -907,16 +1135,30 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{:else if activeTab === 'actions'}
|
{:else if activeTab === 'actions'}
|
||||||
{#if actions.length === 0}
|
<div class="actions-tab">
|
||||||
<div class="empty-state"><p>Действий пока нет</p></div>
|
<div class="tab-toolbar">
|
||||||
{:else}
|
<button class="btn btn-primary" on:click={openCreateAction}>+ Добавить действие</button>
|
||||||
{#each actions as action}
|
</div>
|
||||||
<div class="action-card">
|
{#if actions.length === 0}
|
||||||
<span>{action.title}</span><span class="action-type">{action.type}</span>
|
<div class="empty-state"><p>Действий пока нет</p></div>
|
||||||
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
|
{:else}
|
||||||
</div>
|
{#each actions as action}
|
||||||
{/each}
|
<div class="action-card">
|
||||||
{/if}
|
<div class="action-info">
|
||||||
|
<span class="action-title">{action.title}</span>
|
||||||
|
<span class="action-type">{actionKindLabel(action.type)}</span>
|
||||||
|
<span class="action-data">{action.data}</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-btns">
|
||||||
|
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
|
||||||
|
<button class="btn btn-sm btn-danger" on:click={() => deleteAction(action.id)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{:else if activeTab === 'worklog'}
|
{:else if activeTab === 'worklog'}
|
||||||
<div class="worklog-tab">
|
<div class="worklog-tab">
|
||||||
@@ -939,7 +1181,111 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if activeTab === 'activity'}
|
{:else if activeTab === 'activity'}
|
||||||
<div class="empty-state"><p>Активность появится позже</p></div>
|
<div class="activity-tab">
|
||||||
|
{#if caseActivity.length === 0}
|
||||||
|
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||||||
|
{:else}
|
||||||
|
<div class="activity-events">
|
||||||
|
{#each caseActivity as ev}
|
||||||
|
<div class="activity-event">
|
||||||
|
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
|
||||||
|
<span class="activity-event-title">{ev.title}</span>
|
||||||
|
<span class="activity-event-type">{eventLabel(ev.eventType)}</span>
|
||||||
|
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||||||
|
<span class="activity-event-time">{formatTime(ev.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if selectedSection === 'today' && todayDashboard}
|
||||||
|
<div class="today-dashboard">
|
||||||
|
<div class="today-header">
|
||||||
|
<h2>Сегодня</h2>
|
||||||
|
<span class="today-date">{todayDashboard.date}</span>
|
||||||
|
</div>
|
||||||
|
{#if todayDashboard.summary}
|
||||||
|
<div class="today-summary">
|
||||||
|
{#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, 'дело', 'дела', 'дел')}</span>{/if}
|
||||||
|
{#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, 'заметка', 'заметки', 'заметок')}</span>{/if}
|
||||||
|
{#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, 'файл', 'файла', 'файлов')}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if todayDashboard.groups && todayDashboard.groups.length > 0}
|
||||||
|
{#each todayDashboard.groups as group}
|
||||||
|
<div class="today-case">
|
||||||
|
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
||||||
|
<span class="today-case-title">{group.nodeTitle}</span>
|
||||||
|
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
|
||||||
|
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, 'событие', 'события', 'событий')}</span>{/if}
|
||||||
|
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
|
||||||
|
</div>
|
||||||
|
{#if group.events && group.events.length > 0}
|
||||||
|
<div class="today-events">
|
||||||
|
{#each group.events as ev}
|
||||||
|
<div class="today-event" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
||||||
|
<span class="today-event-icon">{eventIcon(ev.eventType)}</span>
|
||||||
|
<span class="today-event-title">{ev.title}</span>
|
||||||
|
<span class="today-event-type">{eventLabel(ev.eventType)}</span>
|
||||||
|
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||||||
|
<span class="today-event-time">{formatTime(ev.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="today-events-empty">Изменён сегодня</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if todayDashboard.events && todayDashboard.events.length > 0}
|
||||||
|
<div class="today-timeline">
|
||||||
|
<h3>Лента за сегодня</h3>
|
||||||
|
{#each todayDashboard.events as ev}
|
||||||
|
<div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||||||
|
<span class="timeline-dot"></span>
|
||||||
|
<span class="timeline-title">{ev.title}</span>
|
||||||
|
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
|
||||||
|
<span class="timeline-time">{formatTime(ev.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="today-empty">
|
||||||
|
<p>Сегодня пока тихо</p>
|
||||||
|
<p class="hint">Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if selectedSection === 'activity'}
|
||||||
|
<div class="activity-feed">
|
||||||
|
<div class="activity-feed-header">
|
||||||
|
<h2>Активность</h2>
|
||||||
|
</div>
|
||||||
|
{#if activityFeed.length === 0}
|
||||||
|
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||||||
|
{:else}
|
||||||
|
<div class="activity-feed-events">
|
||||||
|
{#each activityFeed as ev}
|
||||||
|
<div class="activity-feed-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||||||
|
<span class="activity-feed-icon">{eventIcon(ev.eventType)}</span>
|
||||||
|
<div class="activity-feed-body">
|
||||||
|
<span class="activity-feed-title">{ev.title}</span>
|
||||||
|
<div class="activity-feed-meta">
|
||||||
|
<span class="activity-feed-type">{eventLabel(ev.eventType)}</span>
|
||||||
|
{#if ev.targetType}<span class="activity-feed-target">{ev.targetType}</span>{/if}
|
||||||
|
<span class="activity-feed-time">{formatDate(ev.createdAt)} {formatTime(ev.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -954,7 +1300,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !noteEditor && !selectedNode}
|
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||||
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
|
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -970,11 +1316,22 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Раздел</label>
|
<label>Раздел</label>
|
||||||
<select bind:value={newNodeSection}>
|
<select bind:value={newNodeSection}>
|
||||||
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox') as s}
|
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox' && s.id !== 'activity') as s}
|
||||||
<option value={s.id}>{s.label}</option>
|
<option value={s.id}>{s.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{#if templates.length > 0}
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Шаблон (опционально)</label>
|
||||||
|
<select bind:value={newNodeTemplate}>
|
||||||
|
<option value="">Без шаблона</option>
|
||||||
|
{#each templates as t}
|
||||||
|
<option value={t.name}>{t.name}{t.description ? ' — ' + t.description : ''}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button>
|
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button>
|
||||||
<button class="btn" on:click={cancelCreateNode}>Отмена</button>
|
<button class="btn" on:click={cancelCreateNode}>Отмена</button>
|
||||||
@@ -983,6 +1340,37 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showCreateAction}
|
||||||
|
<div class="modal-overlay" on:click|self={cancelCreateAction}>
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Новое действие</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" placeholder="Например: Открыть сайт" bind:value={newActionTitle}
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Тип</label>
|
||||||
|
<select bind:value={newActionKind}>
|
||||||
|
{#each actionKinds as k}
|
||||||
|
<option value={k.id}>{k.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{newActionKind === 'open_url' ? 'URL' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? 'Путь' : 'Команда'}</label>
|
||||||
|
<input type="text" placeholder={newActionKind === 'open_url' ? 'https://example.com' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? '/path/to/file' : 'команда'}
|
||||||
|
bind:value={newActionData}
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} />
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" on:click={submitCreateAction}>Создать</button>
|
||||||
|
<button class="btn" on:click={cancelCreateAction}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showImportDialog && importSummary}
|
{#if showImportDialog && importSummary}
|
||||||
<div class="modal-overlay" on:click|self={cancelImport}>
|
<div class="modal-overlay" on:click|self={cancelImport}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -1007,6 +1395,26 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showRename}
|
||||||
|
<div class="modal-overlay" on:click|self={cancelRename}>
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Переименовать</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Новое имя</label>
|
||||||
|
<input type="text" bind:value={renameValue}
|
||||||
|
on:keydown={onRenameKeydown} />
|
||||||
|
</div>
|
||||||
|
{#if renameError}
|
||||||
|
<div class="rename-error">{renameError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button>
|
||||||
|
<button class="btn" on:click={cancelRename}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showConfirm}
|
{#if showConfirm}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title={confirmTitle}
|
title={confirmTitle}
|
||||||
@@ -1107,8 +1515,15 @@
|
|||||||
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
|
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
|
||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
.actions-tab { padding: 24px; }
|
||||||
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
|
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||||
|
.action-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
|
||||||
|
.action-title { font-weight: 500; }
|
||||||
|
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
|
||||||
|
.action-data { font-size: 11px; color: #555; font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
|
||||||
|
.action-btns { display: flex; gap: 4px; flex-shrink: 0; }
|
||||||
|
.action-btns .btn-danger { color: #ff6b6b; border-color: #4a2222; padding: 4px 8px; }
|
||||||
|
.action-btns .btn-danger:hover { background: #3a2222; }
|
||||||
|
|
||||||
/* Empty states */
|
/* Empty states */
|
||||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
|
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
|
||||||
@@ -1159,4 +1574,66 @@
|
|||||||
.import-summary { margin-bottom: 16px; }
|
.import-summary { margin-bottom: 16px; }
|
||||||
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
|
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
|
||||||
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
|
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
|
||||||
|
|
||||||
|
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* Today Dashboard */
|
||||||
|
.today-dashboard { padding: 24px; overflow-y: auto; flex: 1; }
|
||||||
|
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
||||||
|
.today-header h2 { font-size: 24px; }
|
||||||
|
.today-date { font-size: 13px; color: #666; }
|
||||||
|
.today-summary { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||||
|
.summary-chip { font-size: 12px; color: #b0b0c0; background: #1a1a28; border: 1px solid #2a2a3c; padding: 4px 12px; border-radius: 16px; }
|
||||||
|
.today-case { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
|
||||||
|
.today-case-header { padding: 12px 16px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #2a2a3c; cursor: pointer; }
|
||||||
|
.today-case-header:hover { background: #1e1e30; }
|
||||||
|
.today-case-title { font-weight: 500; }
|
||||||
|
.today-case-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
|
||||||
|
.today-case-count { font-size: 11px; color: #6366f1; margin-left: 4px; }
|
||||||
|
.today-case-time { font-size: 11px; color: #555; margin-left: auto; }
|
||||||
|
.today-events { padding: 8px 16px; }
|
||||||
|
.today-event { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; color: #b0b0c0; cursor: pointer; }
|
||||||
|
.today-event:hover { color: #e4e4ef; }
|
||||||
|
.today-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; }
|
||||||
|
.today-event-title { flex: 1; }
|
||||||
|
.today-event-type { font-size: 11px; color: #666; }
|
||||||
|
.today-event-time { font-size: 11px; color: #555; margin-left: auto; }
|
||||||
|
.today-events-empty { padding: 8px 16px; font-size: 13px; color: #666; font-style: italic; }
|
||||||
|
.today-empty { padding: 48px 24px; text-align: center; }
|
||||||
|
.today-empty p { color: #666; font-size: 14px; margin: 0; }
|
||||||
|
.today-empty .hint { font-size: 12px; color: #555; margin-top: 8px; }
|
||||||
|
.today-timeline { margin-top: 24px; }
|
||||||
|
.today-timeline h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 12px; }
|
||||||
|
.timeline-event { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; color: #b0b0c0; border-left: 2px solid #2a2a3c; padding-left: 16px; margin-left: 4px; cursor: pointer; }
|
||||||
|
.timeline-event:hover { color: #e4e4ef; }
|
||||||
|
.timeline-dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; margin-left: -19px; flex-shrink: 0; }
|
||||||
|
.timeline-title { flex: 1; }
|
||||||
|
.timeline-type { font-size: 11px; color: #666; }
|
||||||
|
.timeline-time { font-size: 11px; color: #555; }
|
||||||
|
|
||||||
|
/* Activity tab (per-case) */
|
||||||
|
.activity-tab { padding: 24px; }
|
||||||
|
.activity-events { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.activity-event { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 13px; color: #b0b0c0; cursor: pointer; }
|
||||||
|
.activity-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||||||
|
.activity-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; }
|
||||||
|
.activity-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.activity-event-type { font-size: 11px; color: #666; flex-shrink: 0; }
|
||||||
|
.activity-event-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; }
|
||||||
|
.activity-event-time { font-size: 11px; color: #555; margin-left: 8px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Activity feed (global section) */
|
||||||
|
.activity-feed { padding: 24px; overflow-y: auto; flex: 1; }
|
||||||
|
.activity-feed-header { margin-bottom: 20px; }
|
||||||
|
.activity-feed-header h2 { font-size: 24px; }
|
||||||
|
.activity-feed-events { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.activity-feed-event { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
|
||||||
|
.activity-feed-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||||||
|
.activity-feed-icon { width: 20px; text-align: center; color: #6366f1; font-size: 14px; flex-shrink: 0; margin-top: 1px; }
|
||||||
|
.activity-feed-body { flex: 1; min-width: 0; }
|
||||||
|
.activity-feed-title { font-size: 14px; color: #e4e4ef; }
|
||||||
|
.activity-feed-meta { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
||||||
|
.activity-feed-type { font-size: 11px; color: #666; }
|
||||||
|
.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
|
||||||
|
.activity-feed-time { font-size: 11px; color: #555; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
import FileIcon from './lib/FileIcon.svelte'
|
import FileIcon from './lib/FileIcon.svelte'
|
||||||
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
|
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
|
||||||
|
|
||||||
export let item
|
export let item
|
||||||
export let selected = false
|
export let selected = false
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
const kind = getFileKind(item)
|
const kind = getFileKind(item)
|
||||||
const isFolder = item.type === 'folder'
|
const isFolder = item.type === 'folder'
|
||||||
|
const fileType = formatFileType(item)
|
||||||
let menuOpen = false
|
let menuOpen = false
|
||||||
let clickTimer = null
|
let clickTimer = null
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
if (clickTimer) {
|
if (clickTimer) {
|
||||||
clearTimeout(clickTimer)
|
clearTimeout(clickTimer)
|
||||||
clickTimer = null
|
clickTimer = null
|
||||||
// Double click
|
// Double click → открыть
|
||||||
if (isFolder) {
|
if (isFolder) {
|
||||||
dispatch('navigate', item.id)
|
dispatch('navigate', item.id)
|
||||||
} else {
|
} else {
|
||||||
@@ -34,37 +35,23 @@
|
|||||||
} else {
|
} else {
|
||||||
clickTimer = setTimeout(() => {
|
clickTimer = setTimeout(() => {
|
||||||
clickTimer = null
|
clickTimer = null
|
||||||
// Single click: select
|
// Single click → выделить
|
||||||
if (selected) {
|
dispatch('selectOne', item.id)
|
||||||
// Already selected: navigate/preview
|
|
||||||
if (isFolder) {
|
|
||||||
dispatch('navigate', item.id)
|
|
||||||
} else {
|
|
||||||
dispatch('preview', item)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dispatch('selectOne', item.id)
|
|
||||||
}
|
|
||||||
}, 250)
|
}, 250)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
if (isFolder) {
|
|
||||||
dispatch('navigate', item.id)
|
|
||||||
} else {
|
|
||||||
dispatch('preview', item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpenExternal() {
|
function handleOpenExternal() {
|
||||||
dispatch('openExternal', item.fileId)
|
dispatch('openExternal', item.fileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleShowInFolder() {
|
||||||
|
dispatch('showInFolder', item.id)
|
||||||
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
|
menuOpen = false
|
||||||
dispatch('delete', { id: item.id, type: item.type })
|
dispatch('delete', { id: item.id, type: item.type })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +75,15 @@
|
|||||||
dispatch('copy', item.id)
|
dispatch('copy', item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
menuOpen = false
|
||||||
|
if (isFolder) {
|
||||||
|
dispatch('navigate', item.id)
|
||||||
|
} else {
|
||||||
|
dispatch('preview', item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleMenu() {
|
function toggleMenu() {
|
||||||
menuOpen = !menuOpen
|
menuOpen = !menuOpen
|
||||||
}
|
}
|
||||||
@@ -107,48 +103,48 @@
|
|||||||
function handleDrop(e) {
|
function handleDrop(e) {
|
||||||
if (onDrop && isFolder) onDrop(e, item.id)
|
if (onDrop && isFolder) onDrop(e, item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function oncontextmenu(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
menuOpen = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeMenu}/>
|
<svelte:window on:click={closeMenu}/>
|
||||||
|
|
||||||
<div class="file-row"
|
<div class="file-row"
|
||||||
class:file-row--selected={selected}
|
class:file-row--selected={selected}
|
||||||
class:file-row--dragover={false}
|
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
on:click={handleClick}
|
on:click={handleClick}
|
||||||
on:keydown={handleKeydown}
|
on:contextmenu={oncontextmenu}
|
||||||
on:dragstart={handleDragStart}
|
on:dragstart={handleDragStart}
|
||||||
on:dragover={handleDragOver}
|
on:dragover={handleDragOver}
|
||||||
on:drop={handleDrop}
|
on:drop={handleDrop}
|
||||||
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
|
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
|
||||||
<div class="file-row-icon">
|
<div class="file-row-icon">
|
||||||
<FileIcon {kind} size={22}/>
|
<FileIcon {kind} size={22}/>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-row-body">
|
<div class="file-row-body">
|
||||||
<div class="file-row-name" title={item.name}>{item.name}</div>
|
<div class="file-row-name" title={item.name}>{item.name}</div>
|
||||||
<div class="file-row-meta">
|
<div class="file-row-meta">
|
||||||
{#if isFolder}
|
<span>{fileType}</span>
|
||||||
<span>Folder</span>
|
{#if !isFolder}
|
||||||
{:else}
|
<span class="meta-sep">·</span>
|
||||||
<span>{formatFileSize(item.size)}</span>
|
<span>{formatFileSize(item.size)}</span>
|
||||||
{#if item.mime}
|
|
||||||
<span class="meta-sep">·</span>
|
|
||||||
<span>{formatMimeType(item.mime)}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-row-actions">
|
<div class="file-row-actions">
|
||||||
{#if !isFolder}
|
{#if !isFolder}
|
||||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Preview" aria-label="Preview">
|
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
@@ -156,21 +152,21 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
|
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="More actions" aria-label="More actions" aria-expanded={menuOpen}>
|
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<circle cx="12" cy="5" r="2"/>
|
<circle cx="12" cy="5" r="2"/>
|
||||||
<circle cx="12" cy="12" r="2"/>
|
<circle cx="12" cy="12" r="2"/>
|
||||||
<circle cx="12" cy="19" r="2"/>
|
<circle cx="12" cy="19" r="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Delete" aria-label="Delete">
|
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polyline points="3 6 5 6 21 6"/>
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
@@ -183,10 +179,42 @@
|
|||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
||||||
<div class="menu" on:click|stopPropagation role="menu">
|
<div class="menu" on:click|stopPropagation role="menu">
|
||||||
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button>
|
<button class="menu-item" on:click={handleOpen} role="menuitem">
|
||||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button>
|
<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 class="menu-item" on:click={handleCut} role="menuitem">Cut</button>
|
Открыть
|
||||||
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -201,6 +229,7 @@
|
|||||||
transition: background 0.12s;
|
transition: background 0.12s;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-row:hover {
|
.file-row:hover {
|
||||||
@@ -314,17 +343,19 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
background: #1a1a28;
|
background: #1a1a28;
|
||||||
border: 1px solid #2a2a3c;
|
border: 1px solid #2a2a3c;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
min-width: 140px;
|
min-width: 220px;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 12px;
|
padding: 7px 10px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
@@ -337,10 +368,25 @@
|
|||||||
|
|
||||||
.menu-item:hover {
|
.menu-item:hover {
|
||||||
background: #2a2a3c;
|
background: #2a2a3c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-danger {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-danger:hover {
|
||||||
|
background: #3a2222;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item:focus-visible {
|
.menu-item:focus-visible {
|
||||||
outline: 2px solid #5588ff;
|
outline: 2px solid #5588ff;
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: #2a2a3c;
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as App from '../wailsjs/go/main/App.js'
|
|||||||
|
|
||||||
// Re-export all methods
|
// Re-export all methods
|
||||||
export const listSections = () => App.ListSections()
|
export const listSections = () => App.ListSections()
|
||||||
|
export const listTodayView = () => App.ListTodayView()
|
||||||
export const listNodesBySection = (section) => App.ListNodesBySection(section)
|
export const listNodesBySection = (section) => App.ListNodesBySection(section)
|
||||||
export const listChildren = (parentID) => App.ListChildren(parentID)
|
export const listChildren = (parentID) => App.ListChildren(parentID)
|
||||||
export const getNodeDetail = (id) => App.GetNodeDetail(id)
|
export const getNodeDetail = (id) => App.GetNodeDetail(id)
|
||||||
@@ -31,6 +32,7 @@ export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
|
|||||||
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
|
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
|
||||||
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
|
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
|
||||||
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
|
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
|
||||||
|
export const validateName = (name) => App.ValidateName(name)
|
||||||
|
|
||||||
export const listActions = (nodeID) => App.ListActions(nodeID)
|
export const listActions = (nodeID) => App.ListActions(nodeID)
|
||||||
export const runAction = (id) => App.RunAction(id)
|
export const runAction = (id) => App.RunAction(id)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
||||||
<div class="preview-actions">
|
<div class="preview-actions">
|
||||||
<button class="action-btn" on:click={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
<button class="action-btn" on:click={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
@@ -60,11 +60,11 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="preview-body">
|
<div class="preview-body">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="preview-status"><p>Loading preview...</p></div>
|
<div class="preview-status"><p>Загрузка...</p></div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="preview-status">
|
<div class="preview-status">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if showImage && content}
|
{:else if showImage && content}
|
||||||
<div class="preview-image-container">
|
<div class="preview-image-container">
|
||||||
@@ -79,14 +79,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="preview-status">
|
<div class="preview-status">
|
||||||
<p>PDF preview is not available in this environment.</p>
|
<p>Предпросмотр PDF недоступен.</p>
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="preview-status">
|
<div class="preview-status">
|
||||||
<p>Preview is not available for this file type.</p>
|
<p>Предпросмотр недоступен для этого типа файлов.</p>
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,43 +8,53 @@ export function formatFileSize(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mimeLabels = {
|
const mimeLabels = {
|
||||||
'image/jpeg': 'JPEG image',
|
'image/jpeg': 'Изображение JPEG',
|
||||||
'image/png': 'PNG image',
|
'image/png': 'Изображение PNG',
|
||||||
'image/gif': 'GIF image',
|
'image/gif': 'Изображение GIF',
|
||||||
'image/webp': 'WebP image',
|
'image/webp': 'Изображение WebP',
|
||||||
'image/svg+xml': 'SVG image',
|
'image/svg+xml': 'Изображение SVG',
|
||||||
'image/bmp': 'BMP image',
|
'image/bmp': 'Изображение BMP',
|
||||||
'image/tiff': 'TIFF image',
|
'image/tiff': 'Изображение TIFF',
|
||||||
'image/avif': 'AVIF image',
|
'image/avif': 'Изображение AVIF',
|
||||||
'application/pdf': 'PDF document',
|
'application/pdf': 'PDF документ',
|
||||||
'application/msword': 'Word document',
|
'application/msword': 'Документ Word',
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
|
||||||
'application/vnd.ms-excel': 'Excel spreadsheet',
|
'application/vnd.ms-excel': 'Таблица Excel',
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
|
||||||
'application/vnd.ms-powerpoint': 'PowerPoint presentation',
|
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
|
||||||
'application/zip': 'ZIP archive',
|
'application/zip': 'ZIP архив',
|
||||||
'application/gzip': 'GZIP archive',
|
'application/gzip': 'GZIP архив',
|
||||||
'application/x-tar': 'TAR archive',
|
'application/x-tar': 'TAR архив',
|
||||||
'application/x-7z-compressed': '7z archive',
|
'application/x-7z-compressed': '7z архив',
|
||||||
'application/x-rar-compressed': 'RAR archive',
|
'application/x-rar-compressed': 'RAR архив',
|
||||||
'text/plain': 'Text file',
|
'text/plain': 'Текстовый файл',
|
||||||
'text/html': 'HTML file',
|
'text/html': 'HTML файл',
|
||||||
'text/css': 'CSS file',
|
'text/css': 'CSS файл',
|
||||||
'text/javascript': 'JavaScript file',
|
'text/javascript': 'JavaScript файл',
|
||||||
'application/json': 'JSON file',
|
'application/json': 'JSON файл',
|
||||||
'application/xml': 'XML file',
|
'application/xml': 'XML файл',
|
||||||
'application/x-yaml': 'YAML file',
|
'application/x-yaml': 'YAML файл',
|
||||||
'application/octet-stream': 'Binary file',
|
'application/octet-stream': 'Бинарный файл',
|
||||||
'application/x-msdos-program': 'Executable',
|
'application/x-msdos-program': 'Исполняемый файл',
|
||||||
'inode/directory': 'Folder',
|
'inode/directory': 'Папка',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMimeType(mime) {
|
export function formatMimeType(mime) {
|
||||||
if (!mime) return 'Unknown'
|
if (!mime) return 'Неизвестно'
|
||||||
return mimeLabels[mime] || mime
|
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) {
|
export function getFileKind(item) {
|
||||||
if (item.type === 'folder') return 'folder'
|
if (item.type === 'folder') return 'folder'
|
||||||
const mime = (item.mime || '').toLowerCase()
|
const mime = (item.mime || '').toLowerCase()
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export function ListSections() {
|
|||||||
return window['go']['main']['App']['ListSections']();
|
return window['go']['main']['App']['ListSections']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListTodayView() {
|
||||||
|
return window['go']['main']['App']['ListTodayView']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListNodesBySection(arg1) {
|
export function ListNodesBySection(arg1) {
|
||||||
return window['go']['main']['App']['ListNodesBySection'](arg1);
|
return window['go']['main']['App']['ListNodesBySection'](arg1);
|
||||||
}
|
}
|
||||||
@@ -105,3 +109,19 @@ export function OpenFolder(arg1) {
|
|||||||
export function VerstakVersion() {
|
export function VerstakVersion() {
|
||||||
return window['go']['main']['App']['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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+120
-18
@@ -60,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.
|
||||||
@@ -84,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)
|
||||||
}
|
}
|
||||||
@@ -105,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,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)
|
||||||
}
|
}
|
||||||
@@ -169,11 +235,9 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -190,11 +254,9 @@ func (s *Service) ReadText(id string) (string, error) {
|
|||||||
if rec.Size > maxPreviewSize {
|
if rec.Size > maxPreviewSize {
|
||||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||||
}
|
}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
b, err := os.ReadFile(abs)
|
b, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -212,11 +274,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
|
|||||||
if rec.Size > maxPreviewSize {
|
if rec.Size > maxPreviewSize {
|
||||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||||
}
|
}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
b, err := os.ReadFile(abs)
|
b, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -231,6 +291,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
|
|||||||
|
|
||||||
// CreateEmptyFile creates a file node and an empty vault file.
|
// CreateEmptyFile creates a file node and an empty vault file.
|
||||||
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
|
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)
|
filename = s.uniqueTitle(parentID, filename)
|
||||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -247,6 +310,10 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error
|
|||||||
}
|
}
|
||||||
f.Close()
|
f.Close()
|
||||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
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, "")
|
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
|
||||||
return node, err
|
return node, err
|
||||||
}
|
}
|
||||||
@@ -261,7 +328,7 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
|||||||
if original.ParentID != nil {
|
if original.ParentID != nil {
|
||||||
parentID = *original.ParentID
|
parentID = *original.ParentID
|
||||||
}
|
}
|
||||||
newName := s.uniqueTitle(parentID, original.Title)
|
newName := s.copyTitle(parentID, original.Title)
|
||||||
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
|
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -271,7 +338,10 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
|||||||
if len(records) > 0 {
|
if len(records) > 0 {
|
||||||
src := &records[0]
|
src := &records[0]
|
||||||
if src.StorageMode == "vault" {
|
if src.StorageMode == "vault" {
|
||||||
srcPath := filepath.Join(s.vaultRoot, src.Path)
|
srcPath, err := s.vaultPath(src.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
||||||
os.MkdirAll(dir, 0o750)
|
os.MkdirAll(dir, 0o750)
|
||||||
dst := filepath.Join(dir, newName)
|
dst := filepath.Join(dir, newName)
|
||||||
@@ -280,6 +350,9 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
|||||||
return nil, fmt.Errorf("copy file: %w", err)
|
return nil, fmt.Errorf("copy file: %w", err)
|
||||||
}
|
}
|
||||||
relPath, _ := filepath.Rel(s.vaultRoot, dst)
|
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)
|
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -452,6 +525,35 @@ func (s *Service) uniqueTitle(parentID, desired string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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;
|
||||||
|
`
|
||||||
@@ -64,7 +64,8 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|||||||
Reference in New Issue
Block a user