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/node_modules/
|
||||
frontend/bindings/
|
||||
verstak-gui
|
||||
verstak-cli
|
||||
/verstak-gui
|
||||
/verstak-cli
|
||||
|
||||
# Vault data
|
||||
.verstak/
|
||||
spaces/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
Binary file not shown.
+392
-3
@@ -6,14 +6,18 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/worklog"
|
||||
@@ -28,9 +32,11 @@ type App struct {
|
||||
nodes *nodes.Repository
|
||||
files *files.Service
|
||||
notes *notes.Service
|
||||
activity *activity.Service
|
||||
actions *actions.Service
|
||||
worklog *worklog.Service
|
||||
search *search.Service
|
||||
plugins *plugins.Manager
|
||||
vault string
|
||||
}
|
||||
|
||||
@@ -115,6 +121,47 @@ type SearchResultDTO struct {
|
||||
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
|
||||
// ============================================================
|
||||
@@ -123,6 +170,7 @@ func (a *App) ListSections() []SectionDTO {
|
||||
return []SectionDTO{
|
||||
{ID: "today", Label: "Сегодня"},
|
||||
{ID: "inbox", Label: "Неразобранное"},
|
||||
{ID: "activity", Label: "Активность"},
|
||||
{ID: "clients", Label: "Клиенты"},
|
||||
{ID: "projects", Label: "Проекты"},
|
||||
{ID: "recipes", Label: "Рецепты"},
|
||||
@@ -143,6 +191,175 @@ func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
|
||||
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) {
|
||||
list, err := a.nodes.ListChildren(parentID, false)
|
||||
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) {
|
||||
if section == "today" || section == "inbox" {
|
||||
return nil, fmt.Errorf("cannot create node with section %q", section)
|
||||
}
|
||||
n, err := a.nodes.Create(parentID, nodeType, title, section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
|
||||
dto := toNodeDTO(n)
|
||||
return &dto, nil
|
||||
}
|
||||
@@ -173,6 +394,66 @@ func (a *App) DeleteNode(id string) error {
|
||||
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
|
||||
// ============================================================
|
||||
@@ -198,6 +479,7 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
@@ -209,7 +491,18 @@ func (a *App) ReadNote(noteID string) (string, error) {
|
||||
|
||||
// SaveNote saves note content.
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -286,10 +582,27 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -298,6 +611,7 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
@@ -307,16 +621,73 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Find parent for recording
|
||||
n, err2 := a.nodes.GetActive(nodeID)
|
||||
pid := ""
|
||||
if err2 == nil && n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
@@ -349,6 +720,24 @@ func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
|
||||
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 {
|
||||
_, err := a.actions.Run(id)
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
|
||||
<script type="module" crossorigin src="/assets/main-DZkGJWBF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BnVt-oqm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+14
-9
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
@@ -46,19 +47,23 @@ func main() {
|
||||
fileSvc := files.NewService(db, abs, nodeRepo)
|
||||
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
activitySvc := activity.NewService(db)
|
||||
worklogSvc := worklog.NewService(db)
|
||||
searchSvc := search.NewService(db)
|
||||
plugins.NewManager(abs).Discover()
|
||||
pm := plugins.NewManager(abs)
|
||||
pm.Discover()
|
||||
|
||||
app := &App{
|
||||
db: db,
|
||||
nodes: nodeRepo,
|
||||
files: fileSvc,
|
||||
notes: noteSvc,
|
||||
actions: actionSvc,
|
||||
worklog: worklogSvc,
|
||||
search: searchSvc,
|
||||
vault: abs,
|
||||
db: db,
|
||||
nodes: nodeRepo,
|
||||
files: fileSvc,
|
||||
notes: noteSvc,
|
||||
activity: activitySvc,
|
||||
actions: actionSvc,
|
||||
worklog: worklogSvc,
|
||||
search: searchSvc,
|
||||
plugins: pm,
|
||||
vault: abs,
|
||||
}
|
||||
|
||||
err = wails.Run(&options.App{
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
```text
|
||||
┌────────────────────┐
|
||||
│ GUI/Wails │
|
||||
│ GUI/Wails v2 │
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
┌─────────────┐ ┌───────▼────────┐ ┌─────────────┐
|
||||
│ TUI/Bubble │───▶│ Core Library │◀───│ CLI Commands │
|
||||
└─────────────┘ └───────┬────────┘ └─────────────┘
|
||||
┌───────▼────────┐ ┌─────────────┐
|
||||
│ Core Library │◀───│ CLI Commands │
|
||||
└───────┬────────┘ └─────────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ Local Vault+SQLite │
|
||||
@@ -161,8 +161,6 @@ verstak/
|
||||
activity/
|
||||
search/
|
||||
importers/
|
||||
sync/
|
||||
security/
|
||||
config/
|
||||
|
||||
gui/
|
||||
|
||||
+8
-8
@@ -18,23 +18,23 @@
|
||||
- import/dokuwiki/mysql-cleanup.txt
|
||||
|
||||
Быстрые действия:
|
||||
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync]
|
||||
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync] *(future)*
|
||||
```
|
||||
|
||||
## 2. Общий layout
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Search | Quick Add | Sync Status | Settings │
|
||||
├───────────────┬────────────────────────────────────┤
|
||||
│ Tree sidebar │ Main content │
|
||||
│ │ │
|
||||
│ Сегодня │ Case / Note / File / Search │
|
||||
│ Клиенты │ │
|
||||
┌───────────────┬────────────────────────────────────┐
|
||||
│ Tree sidebar │ Header: Section/Node Title │
|
||||
│ ├────────────────────────────────────┤
|
||||
│ Сегодня │ Main content │
|
||||
│ Клиенты │ Case / Note / File / Search │
|
||||
│ Проекты │ │
|
||||
│ Рецепты │ │
|
||||
│ Документы │ │
|
||||
└───────────────┴────────────────────────────────────┘
|
||||
|
||||
*Примечание: Quick Add и Sync Status из оригинального макета пока не реализованы — в планах на будущее.*
|
||||
```
|
||||
|
||||
## 3. Sidebar tree
|
||||
|
||||
+9
-5
@@ -1,5 +1,7 @@
|
||||
# Верстак — 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
|
||||
|
||||
- Go module;
|
||||
@@ -65,6 +67,8 @@ Acceptance:
|
||||
|
||||
## 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;
|
||||
- sidebar tree;
|
||||
- create node/note;
|
||||
@@ -140,7 +144,7 @@ Acceptance:
|
||||
|
||||
- можно постепенно разобрать папку `work`.
|
||||
|
||||
## Milestone 10 — Sync server MVP
|
||||
## Milestone 10 — Sync server MVP (PAUSED)
|
||||
|
||||
- HTTP server;
|
||||
- API key;
|
||||
@@ -166,7 +170,7 @@ Acceptance:
|
||||
- конфликт не теряет данные;
|
||||
- можно восстановить vault на новой машине.
|
||||
|
||||
## Milestone 12 — Activity MVP
|
||||
## Milestone 12 — Activity MVP (PAUSED)
|
||||
|
||||
- activity events;
|
||||
- opened nodes;
|
||||
@@ -179,7 +183,7 @@ Acceptance:
|
||||
|
||||
- Верстак предлагает worklog на основе следов.
|
||||
|
||||
## Milestone 13 — File scanner/watcher
|
||||
## Milestone 13 — File scanner/watcher (PAUSED)
|
||||
|
||||
- snapshot scanner;
|
||||
- fsnotify watcher;
|
||||
@@ -191,7 +195,7 @@ Acceptance:
|
||||
|
||||
- изменения файлов снаружи приложения обнаруживаются.
|
||||
|
||||
## Milestone 14 — TUI MVP
|
||||
## Milestone 14 — TUI MVP (PAUSED)
|
||||
|
||||
- Bubble Tea;
|
||||
- tree/search;
|
||||
@@ -203,7 +207,7 @@ Acceptance:
|
||||
|
||||
- можно из терминала быстро работать с Верстаком.
|
||||
|
||||
## Milestone 15 — 1.0 polish
|
||||
## Milestone 15 — 1.0 polish (PAUSED)
|
||||
|
||||
- installers;
|
||||
- backup export;
|
||||
|
||||
+13
-13
@@ -6,19 +6,19 @@
|
||||
|
||||
## Обязательные функции
|
||||
|
||||
- [ ] Создать vault.
|
||||
- [ ] Создать дерево дел.
|
||||
- [ ] Создать Markdown-заметку.
|
||||
- [ ] Редактировать Markdown-заметку.
|
||||
- [ ] Добавить файл в дело.
|
||||
- [ ] Открыть файл системным приложением.
|
||||
- [ ] Создать action “Открыть URL”.
|
||||
- [ ] Создать action “Открыть папку”.
|
||||
- [ ] Создать action “Запустить команду”.
|
||||
- [ ] Добавить запись работы.
|
||||
- [ ] Скопировать отчёт по работам.
|
||||
- [ ] Поиск по заметкам.
|
||||
- [ ] Поиск по именам файлов.
|
||||
- [x] Создать vault.
|
||||
- [x] Создать дерево дел.
|
||||
- [x] Создать Markdown-заметку.
|
||||
- [x] Редактировать Markdown-заметку.
|
||||
- [x] Добавить файл в дело.
|
||||
- [x] Открыть файл системным приложением.
|
||||
- [x] Создать action “Открыть URL”.
|
||||
- [x] Создать action “Открыть папку”.
|
||||
- [x] Создать action “Запустить команду”.
|
||||
- [x] Добавить запись работы.
|
||||
- [x] Скопировать отчёт по работам.
|
||||
- [x] Поиск по заметкам.
|
||||
- [x] Поиск по именам файлов.
|
||||
- [x] Поиск по журналу работ.
|
||||
- [x] Базовый импорт DokuWiki (плагин).
|
||||
|
||||
|
||||
+94
-61
@@ -21,21 +21,21 @@
|
||||
| 8 | FTS5 Search | ✅ выполнен |
|
||||
| 9 | Section assignment + Sidebar filtering | ✅ выполнен |
|
||||
| 10 | Plugin Manager (discovery + templates) | ✅ выполнен |
|
||||
| 11 | **Wails Desktop GUI** | 🔄 Wails v2 vertical MVP |
|
||||
| 12 | **Files/Folders full workflow** | ⬜ следующий этап после vertical MVP |
|
||||
| 13 | **Drag-and-drop** | ⬜ не начат |
|
||||
| 14 | **MVP stabilization** | ⬜ не начат |
|
||||
| 15 | Sync Server Skeleton | 🔒 PAUSED |
|
||||
| 16 | Sync Client MVP | 🔒 PAUSED |
|
||||
| 17 | Activity + File Scanner/Watcher | 🔒 PAUSED |
|
||||
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED |
|
||||
| 19 | Integrity Check + Repair | 🔒 PAUSED |
|
||||
| 20 | Plugins: Lua runtime | 🔒 PAUSED |
|
||||
| 21 | DokuWiki Importer (plugin) | 🔒 PAUSED |
|
||||
| 22 | Calendar/Kanban | 🔒 PAUSED |
|
||||
| 23 | New templates/integrations | 🔒 PAUSED |
|
||||
| 11 | **Wails Desktop GUI** | ✅ выполнено (v2, full Svelte UI) |
|
||||
| 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) |
|
||||
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
|
||||
| 14 | **MVP stabilization** | 🔄 в процессе — smoke-тесты, docs, go test |
|
||||
| 15 | Sync Server + Client | 🔒 PAUSED — HTTP API key, push/pull, blob sync |
|
||||
| 16 | Activity Suggestions | 🔒 PAUSED — worklog suggestions from activity_events |
|
||||
| 17 | File Scanner/Watcher | 🔒 PAUSED — fsnotify, snapshot scanner, missing file detection |
|
||||
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED — tree/search, add worklog, run action, sync |
|
||||
| 19 | Plugins: Lua runtime | 🔒 PAUSED — gopher-lua, hooks, sandbox |
|
||||
| 20 | Browser Extension | 🔒 PAUSED — tracking, capture, evidence |
|
||||
| 21 | Calendar/Kanban | 🔒 PAUSED — view by date, board view |
|
||||
| 22 | Integrity Check + Repair | 🔒 PAUSED — checksums, crash recovery |
|
||||
| 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`).
|
||||
|
||||
@@ -47,35 +47,37 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/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)
|
||||
|
||||
---
|
||||
|
||||
## Текущий этап: Wails v2 Vertical MVP
|
||||
## Текущий этап: MVP Stabilization
|
||||
|
||||
**Цель:** базовый рабочий desktop GUI для разделов → дел → заметок.
|
||||
**Цель:** стабилизация MVP — smoke-тесты, go test, документация.
|
||||
|
||||
**Прогресс:**
|
||||
**Прогресс Wails v2 Desktop GUI:**
|
||||
- ✅ Wails v2 shell (window opens, no SIGSEGV)
|
||||
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
|
||||
- 🔄 Notes bindings + UI
|
||||
- 🔄 Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
|
||||
- 🔄 Node creation
|
||||
- 🔄 Section filtering
|
||||
- ✅ Notes bindings + UI (create/read/save/dirty state)
|
||||
- ✅ Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
|
||||
- ✅ Node creation + template selection (FromTemplate)
|
||||
- ✅ 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):**
|
||||
- Файлы/папки workflow
|
||||
- Drag-and-drop
|
||||
- Sync, plugins, Lua, browser extension, TUI
|
||||
- Новые шаблоны, DokuWiki importer
|
||||
**Среднесрочные шаги (заморожены до стабилизации MVP):**
|
||||
- Sync server/client
|
||||
- File Scanner/Watcher
|
||||
- TUI (Bubble Tea)
|
||||
- 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
|
||||
5. Оставить текущий `internal/gui/` как legacy (не удалять, но не развивать)
|
||||
|
||||
### Backend bindings (минимум)
|
||||
### Backend bindings (Wails v2 — реализовано)
|
||||
|
||||
```go
|
||||
// Nodes
|
||||
ListSections() ([]SectionDTO, error)
|
||||
ListNodes(section string) ([]NodeDTO, error)
|
||||
GetNodeDetail(nodeID string) (NodeDetailDTO, error)
|
||||
CreateNode(parentID, section, typ, title string) (NodeDTO, error)
|
||||
FromTemplate(parentID, section, typ, title, template string) (NodeDTO, error)
|
||||
ListSections() []SectionDTO
|
||||
ListNodesBySection(section string) ([]NodeDTO, error)
|
||||
GetNodeDetail(nodeID string) (*NodeDTO, error)
|
||||
ListChildren(parentID string) ([]NodeDTO, error)
|
||||
CreateNode(parentID, typ, title, section string) (*NodeDTO, 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
|
||||
CreateNote(parentID, title string) (NodeDTO, error)
|
||||
ListNotes(nodeID string) ([]NodeDTO, error)
|
||||
CreateNote(parentID, title string) (*NodeDTO, error)
|
||||
ReadNote(noteID string) (string, error)
|
||||
SaveNote(noteID, content string) error
|
||||
|
||||
// Files
|
||||
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)
|
||||
DeleteFileOrFolder(id string) error
|
||||
OpenFile(id string) error
|
||||
OpenFolder(id string) error
|
||||
DeleteFileOrFolder(nodeID string) error
|
||||
CreateEmptyFile(parentID, filename string) (*NodeDTO, 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)
|
||||
PickFiles() ([]string, error)
|
||||
PickDirectory() (string, error)
|
||||
|
||||
// Actions/Worklog/Search
|
||||
// Actions
|
||||
ListActions(nodeID string) ([]ActionDTO, error)
|
||||
CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
|
||||
DeleteAction(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)
|
||||
|
||||
// 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/
|
||||
verstak/ # CLI
|
||||
verstak-gui/ # Wails GUI main
|
||||
verstak-tui/ # Bubble Tea TUI
|
||||
verstak-server/ # Sync server
|
||||
verstak-gui/ # Wails v2 GUI main
|
||||
|
||||
frontend/ # Wails frontend
|
||||
frontend/ # Wails v2 Svelte frontend
|
||||
package.json
|
||||
wails.json
|
||||
vite.config.js
|
||||
src/
|
||||
App.svelte
|
||||
components/
|
||||
stores/
|
||||
styles/
|
||||
FileTreeRow.svelte
|
||||
lib/
|
||||
FileBreadcrumbs.svelte
|
||||
FilePreviewModal.svelte
|
||||
ConfirmModal.svelte
|
||||
FileIcon.svelte
|
||||
FileActions.svelte
|
||||
fileUtils.js
|
||||
api/verstak.js
|
||||
|
||||
internal/
|
||||
core/
|
||||
@@ -335,10 +368,8 @@ verstak/
|
||||
worklog/
|
||||
activity/
|
||||
search/
|
||||
sync/
|
||||
security/
|
||||
config/
|
||||
plugins/ # manager, lua (stub)
|
||||
plugins/ # manager, lua (stub), builtin templates
|
||||
|
||||
contrib/
|
||||
plugins/
|
||||
@@ -351,11 +382,13 @@ verstak/
|
||||
004_add_notes.sql
|
||||
005_add_actions.sql
|
||||
006_add_worklog.sql
|
||||
007_search.sql
|
||||
008_sync.sql
|
||||
009_section.sql
|
||||
```
|
||||
|
||||
## RAID
|
||||
|
||||
- **Критично:** Wails требует Node.js для frontend-сборки
|
||||
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
|
||||
- **Зависимость:** Steps 15+ ждут завершения step 14
|
||||
- **Риск:** Wails v3 может быть нестабилен — проверить перед шагом 11
|
||||
- **Зависимость:** Steps 15+ ждут завершения step 14 (MVP stabilization)
|
||||
|
||||
+516
-39
@@ -26,6 +26,12 @@
|
||||
// ===== State =====
|
||||
let sections = []
|
||||
let nodes = []
|
||||
let todayDashboard = null
|
||||
let activityFeed = []
|
||||
let activityOffset = 0
|
||||
let activityHasMore = true
|
||||
let activityLoading = false
|
||||
let caseActivity = []
|
||||
let version = ''
|
||||
let error = ''
|
||||
let selectedSection = ''
|
||||
@@ -41,8 +47,23 @@
|
||||
let showCreateNode = false
|
||||
let newNodeTitle = ''
|
||||
let newNodeSection = 'clients'
|
||||
let newNodeTemplate = ''
|
||||
let templates = []
|
||||
let showCreateNote = false
|
||||
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 importing = false
|
||||
let importSummary = null
|
||||
@@ -72,6 +93,11 @@
|
||||
let confirmAction = null
|
||||
let cancelAction = null
|
||||
|
||||
let showRename = false
|
||||
let renameId = ''
|
||||
let renameValue = ''
|
||||
let renameError = ''
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'notes', label: 'Заметки' },
|
||||
@@ -94,6 +120,7 @@
|
||||
sections = [
|
||||
{ id: 'today', label: 'Сегодня' },
|
||||
{ id: 'inbox', label: 'Неразобранное' },
|
||||
{ id: 'activity', label: 'Активность' },
|
||||
{ id: 'clients', label: 'Клиенты' },
|
||||
{ id: 'projects', label: 'Проекты' },
|
||||
{ id: 'recipes', label: 'Рецепты' },
|
||||
@@ -129,11 +156,26 @@
|
||||
worklog = []
|
||||
showCreateNode = false
|
||||
error = ''
|
||||
todayDashboard = null
|
||||
activityFeed = []
|
||||
activityOffset = 0
|
||||
activityHasMore = true
|
||||
nodes = []
|
||||
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) {
|
||||
error = String(e)
|
||||
nodes = []
|
||||
todayDashboard = { cases: [] }
|
||||
activityFeed = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +200,7 @@
|
||||
showCreateNode = false
|
||||
showCreateNote = false
|
||||
error = ''
|
||||
caseActivity = []
|
||||
await loadTabData(node.id)
|
||||
}
|
||||
|
||||
@@ -166,6 +209,7 @@
|
||||
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
||||
try { actions = await wailsCall('ListActions', 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) {
|
||||
@@ -282,19 +326,8 @@
|
||||
}
|
||||
|
||||
function renameItem(id) {
|
||||
const name = prompt('Новое имя:')
|
||||
if (!name || !name.trim()) return
|
||||
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)
|
||||
}
|
||||
const item = fileItems.find(x => x.id === id)
|
||||
if (item) openRename(item.id, item.name)
|
||||
}
|
||||
|
||||
function cutItem(id) {
|
||||
@@ -343,6 +376,20 @@
|
||||
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() {
|
||||
selectedIds = []
|
||||
}
|
||||
@@ -421,15 +468,106 @@
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'c') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v') { e.preventDefault(); pasteItem() }
|
||||
else if (e.key === 'a') { e.preventDefault(); selectAll() }
|
||||
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
|
||||
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') {
|
||||
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 =====
|
||||
|
||||
function openConfirm(opts) {
|
||||
@@ -463,14 +601,22 @@
|
||||
showCreateNode = true
|
||||
newNodeTitle = ''
|
||||
newNodeSection = selectedSection || 'clients'
|
||||
newNodeTemplate = ''
|
||||
wailsCall('ListTemplates').then(t => { templates = t || [] }).catch(() => { templates = [] })
|
||||
}
|
||||
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
|
||||
async function submitCreateNode() {
|
||||
if (!newNodeTitle.trim()) return
|
||||
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
|
||||
newNodeTitle = ''
|
||||
newNodeTemplate = ''
|
||||
await selectSection(newNodeSection)
|
||||
} catch (e) { error = String(e) }
|
||||
}
|
||||
@@ -652,10 +798,90 @@
|
||||
|
||||
// ===== Helpers =====
|
||||
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) {
|
||||
if (!str) return ''
|
||||
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>
|
||||
|
||||
<div class="app">
|
||||
@@ -675,7 +901,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if selectedSection}
|
||||
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
|
||||
{#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>
|
||||
Добавить файл
|
||||
</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>
|
||||
Добавить действие
|
||||
</button>
|
||||
@@ -832,7 +1058,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#if folderStack.length > 0}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => {
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => {
|
||||
const i = e.detail
|
||||
if (i === 0) {
|
||||
folderStack = []
|
||||
@@ -847,7 +1073,7 @@
|
||||
Back
|
||||
</button>
|
||||
{:else}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/>
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/>
|
||||
{/if}
|
||||
|
||||
{#if fileItems.length === 0}
|
||||
@@ -877,6 +1103,7 @@
|
||||
on:navigate={(e) => navigateToFolder(e.detail)}
|
||||
on:preview={(e) => openPreview(e.detail)}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
|
||||
on:delete={(e) => deleteFile(e.detail)}
|
||||
on:rename={(e) => renameItem(e.detail.id)}
|
||||
on:duplicate={(e) => duplicateItem(e.detail)}
|
||||
@@ -884,6 +1111,7 @@
|
||||
on:copy={(e) => copyItem(e.detail)}
|
||||
on:selectOne={(e) => selectOne(e.detail)}
|
||||
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
||||
on:rangeSelect={(e) => rangeSelect(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -907,16 +1135,30 @@
|
||||
{/if}
|
||||
|
||||
{:else if activeTab === 'actions'}
|
||||
{#if actions.length === 0}
|
||||
<div class="empty-state"><p>Действий пока нет</p></div>
|
||||
{:else}
|
||||
{#each actions as action}
|
||||
<div class="action-card">
|
||||
<span>{action.title}</span><span class="action-type">{action.type}</span>
|
||||
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="actions-tab">
|
||||
<div class="tab-toolbar">
|
||||
<button class="btn btn-primary" on:click={openCreateAction}>+ Добавить действие</button>
|
||||
</div>
|
||||
{#if actions.length === 0}
|
||||
<div class="empty-state"><p>Действий пока нет</p></div>
|
||||
{:else}
|
||||
{#each actions as action}
|
||||
<div class="action-card">
|
||||
<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'}
|
||||
<div class="worklog-tab">
|
||||
@@ -939,7 +1181,111 @@
|
||||
</div>
|
||||
|
||||
{: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}
|
||||
</div>
|
||||
|
||||
@@ -954,7 +1300,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !noteEditor && !selectedNode}
|
||||
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
|
||||
{/if}
|
||||
|
||||
@@ -970,11 +1316,22 @@
|
||||
<div class="form-group">
|
||||
<label>Раздел</label>
|
||||
<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>
|
||||
{/each}
|
||||
</select>
|
||||
</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">
|
||||
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button>
|
||||
<button class="btn" on:click={cancelCreateNode}>Отмена</button>
|
||||
@@ -983,6 +1340,37 @@
|
||||
</div>
|
||||
{/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}
|
||||
<div class="modal-overlay" on:click|self={cancelImport}>
|
||||
<div class="modal">
|
||||
@@ -1007,6 +1395,26 @@
|
||||
</div>
|
||||
{/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}
|
||||
<ConfirmModal
|
||||
title={confirmTitle}
|
||||
@@ -1107,8 +1515,15 @@
|
||||
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
|
||||
|
||||
/* Actions */
|
||||
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
|
||||
.actions-tab { padding: 24px; }
|
||||
.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-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; }
|
||||
.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; }
|
||||
|
||||
.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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from '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 selected = false
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
const kind = getFileKind(item)
|
||||
const isFolder = item.type === 'folder'
|
||||
const fileType = formatFileType(item)
|
||||
let menuOpen = false
|
||||
let clickTimer = null
|
||||
|
||||
@@ -25,7 +26,7 @@
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer)
|
||||
clickTimer = null
|
||||
// Double click
|
||||
// Double click → открыть
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
@@ -34,37 +35,23 @@
|
||||
} else {
|
||||
clickTimer = setTimeout(() => {
|
||||
clickTimer = null
|
||||
// Single click: select
|
||||
if (selected) {
|
||||
// Already selected: navigate/preview
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
} else {
|
||||
dispatch('selectOne', item.id)
|
||||
}
|
||||
// Single click → выделить
|
||||
dispatch('selectOne', item.id)
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenExternal() {
|
||||
dispatch('openExternal', item.fileId)
|
||||
}
|
||||
|
||||
function handleShowInFolder() {
|
||||
dispatch('showInFolder', item.id)
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
menuOpen = false
|
||||
dispatch('delete', { id: item.id, type: item.type })
|
||||
}
|
||||
|
||||
@@ -88,6 +75,15 @@
|
||||
dispatch('copy', item.id)
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
menuOpen = false
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen = !menuOpen
|
||||
}
|
||||
@@ -107,48 +103,48 @@
|
||||
function handleDrop(e) {
|
||||
if (onDrop && isFolder) onDrop(e, item.id)
|
||||
}
|
||||
|
||||
function oncontextmenu(e) {
|
||||
e.preventDefault()
|
||||
menuOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeMenu}/>
|
||||
|
||||
<div class="file-row"
|
||||
class:file-row--selected={selected}
|
||||
class:file-row--dragover={false}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
on:click={handleClick}
|
||||
on:keydown={handleKeydown}
|
||||
on:contextmenu={oncontextmenu}
|
||||
on:dragstart={handleDragStart}
|
||||
on:dragover={handleDragOver}
|
||||
on:drop={handleDrop}
|
||||
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
|
||||
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
|
||||
<div class="file-row-icon">
|
||||
<FileIcon {kind} size={22}/>
|
||||
</div>
|
||||
<div class="file-row-body">
|
||||
<div class="file-row-name" title={item.name}>{item.name}</div>
|
||||
<div class="file-row-meta">
|
||||
{#if isFolder}
|
||||
<span>Folder</span>
|
||||
{:else}
|
||||
<span>{fileType}</span>
|
||||
{#if !isFolder}
|
||||
<span class="meta-sep">·</span>
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
{#if item.mime}
|
||||
<span class="meta-sep">·</span>
|
||||
<span>{formatMimeType(item.mime)}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-row-actions">
|
||||
{#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">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="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">
|
||||
<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"/>
|
||||
@@ -156,21 +152,21 @@
|
||||
</svg>
|
||||
</button>
|
||||
{: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">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="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">
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<circle cx="12" cy="19" r="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="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">
|
||||
<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"/>
|
||||
@@ -183,10 +179,42 @@
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
||||
<div class="menu" on:click|stopPropagation role="menu">
|
||||
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button>
|
||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button>
|
||||
<button class="menu-item" on:click={handleCut} role="menuitem">Cut</button>
|
||||
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button>
|
||||
<button class="menu-item" on:click={handleOpen} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
Открыть
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Открыть во внешней программе
|
||||
</button>
|
||||
{#if isFolder}
|
||||
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
Показать в проводнике
|
||||
</button>
|
||||
{/if}
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item" on:click={handleRename} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Переименовать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Дублировать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleCut} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
|
||||
Вырезать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleCopy} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Копировать
|
||||
</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -201,6 +229,7 @@
|
||||
transition: background 0.12s;
|
||||
min-height: 52px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
@@ -314,17 +343,19 @@
|
||||
margin-top: 4px;
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
min-width: 140px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
min-width: 220px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
@@ -337,10 +368,25 @@
|
||||
|
||||
.menu-item:hover {
|
||||
background: #2a2a3c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.menu-item-danger {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.menu-item-danger:hover {
|
||||
background: #3a2222;
|
||||
}
|
||||
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.menu-sep {
|
||||
height: 1px;
|
||||
background: #2a2a3c;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as App from '../wailsjs/go/main/App.js'
|
||||
|
||||
// Re-export all methods
|
||||
export const listSections = () => App.ListSections()
|
||||
export const listTodayView = () => App.ListTodayView()
|
||||
export const listNodesBySection = (section) => App.ListNodesBySection(section)
|
||||
export const listChildren = (parentID) => App.ListChildren(parentID)
|
||||
export const getNodeDetail = (id) => App.GetNodeDetail(id)
|
||||
@@ -31,6 +32,7 @@ export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
|
||||
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
|
||||
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
|
||||
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
|
||||
export const validateName = (name) => App.ValidateName(name)
|
||||
|
||||
export const listActions = (nodeID) => App.ListActions(nodeID)
|
||||
export const runAction = (id) => App.RunAction(id)
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
||||
<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">
|
||||
<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"/>
|
||||
@@ -60,11 +60,11 @@
|
||||
</header>
|
||||
<div class="preview-body">
|
||||
{#if loading}
|
||||
<div class="preview-status"><p>Loading preview...</p></div>
|
||||
<div class="preview-status"><p>Загрузка...</p></div>
|
||||
{:else if error}
|
||||
<div class="preview-status">
|
||||
<p>{error}</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{:else if showImage && content}
|
||||
<div class="preview-image-container">
|
||||
@@ -79,14 +79,14 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="preview-status">
|
||||
<p>PDF preview is not available in this environment.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
<p>Предпросмотр PDF недоступен.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="preview-status">
|
||||
<p>Preview is not available for this file type.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
<p>Предпросмотр недоступен для этого типа файлов.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -8,43 +8,53 @@ export function formatFileSize(bytes) {
|
||||
}
|
||||
|
||||
const mimeLabels = {
|
||||
'image/jpeg': 'JPEG image',
|
||||
'image/png': 'PNG image',
|
||||
'image/gif': 'GIF image',
|
||||
'image/webp': 'WebP image',
|
||||
'image/svg+xml': 'SVG image',
|
||||
'image/bmp': 'BMP image',
|
||||
'image/tiff': 'TIFF image',
|
||||
'image/avif': 'AVIF image',
|
||||
'application/pdf': 'PDF document',
|
||||
'application/msword': 'Word document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
|
||||
'application/vnd.ms-excel': 'Excel spreadsheet',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
|
||||
'application/vnd.ms-powerpoint': 'PowerPoint presentation',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
|
||||
'application/zip': 'ZIP archive',
|
||||
'application/gzip': 'GZIP archive',
|
||||
'application/x-tar': 'TAR archive',
|
||||
'application/x-7z-compressed': '7z archive',
|
||||
'application/x-rar-compressed': 'RAR archive',
|
||||
'text/plain': 'Text file',
|
||||
'text/html': 'HTML file',
|
||||
'text/css': 'CSS file',
|
||||
'text/javascript': 'JavaScript file',
|
||||
'application/json': 'JSON file',
|
||||
'application/xml': 'XML file',
|
||||
'application/x-yaml': 'YAML file',
|
||||
'application/octet-stream': 'Binary file',
|
||||
'application/x-msdos-program': 'Executable',
|
||||
'inode/directory': 'Folder',
|
||||
'image/jpeg': 'Изображение JPEG',
|
||||
'image/png': 'Изображение PNG',
|
||||
'image/gif': 'Изображение GIF',
|
||||
'image/webp': 'Изображение WebP',
|
||||
'image/svg+xml': 'Изображение SVG',
|
||||
'image/bmp': 'Изображение BMP',
|
||||
'image/tiff': 'Изображение TIFF',
|
||||
'image/avif': 'Изображение AVIF',
|
||||
'application/pdf': 'PDF документ',
|
||||
'application/msword': 'Документ Word',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
|
||||
'application/vnd.ms-excel': 'Таблица Excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
|
||||
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
|
||||
'application/zip': 'ZIP архив',
|
||||
'application/gzip': 'GZIP архив',
|
||||
'application/x-tar': 'TAR архив',
|
||||
'application/x-7z-compressed': '7z архив',
|
||||
'application/x-rar-compressed': 'RAR архив',
|
||||
'text/plain': 'Текстовый файл',
|
||||
'text/html': 'HTML файл',
|
||||
'text/css': 'CSS файл',
|
||||
'text/javascript': 'JavaScript файл',
|
||||
'application/json': 'JSON файл',
|
||||
'application/xml': 'XML файл',
|
||||
'application/x-yaml': 'YAML файл',
|
||||
'application/octet-stream': 'Бинарный файл',
|
||||
'application/x-msdos-program': 'Исполняемый файл',
|
||||
'inode/directory': 'Папка',
|
||||
}
|
||||
|
||||
export function formatMimeType(mime) {
|
||||
if (!mime) return 'Unknown'
|
||||
if (!mime) return 'Неизвестно'
|
||||
return mimeLabels[mime] || mime
|
||||
}
|
||||
|
||||
export function formatFileType(item) {
|
||||
if (item.type === 'folder') return 'Папка'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
if (mimeLabels[mime]) return mimeLabels[mime]
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
if (ext) return ext.toUpperCase()
|
||||
return 'Файл'
|
||||
}
|
||||
|
||||
export function getFileKind(item) {
|
||||
if (item.type === 'folder') return 'folder'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
|
||||
@@ -6,6 +6,10 @@ export function ListSections() {
|
||||
return window['go']['main']['App']['ListSections']();
|
||||
}
|
||||
|
||||
export function ListTodayView() {
|
||||
return window['go']['main']['App']['ListTodayView']();
|
||||
}
|
||||
|
||||
export function ListNodesBySection(arg1) {
|
||||
return window['go']['main']['App']['ListNodesBySection'](arg1);
|
||||
}
|
||||
@@ -105,3 +109,19 @@ export function OpenFolder(arg1) {
|
||||
export function VerstakVersion() {
|
||||
return window['go']['main']['App']['VerstakVersion']();
|
||||
}
|
||||
|
||||
export function ListActivityFeed(arg1, arg2) {
|
||||
return window['go']['main']['App']['ListActivityFeed'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ListActivityByNode(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ListActivityByNode'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function CountActivityByNode(arg1) {
|
||||
return window['go']['main']['App']['CountActivityByNode'](arg1);
|
||||
}
|
||||
|
||||
export function CreateEmptyFile(arg1, arg2) {
|
||||
return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// --- 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 ---
|
||||
|
||||
// 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)
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -149,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
|
||||
return err
|
||||
}
|
||||
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")
|
||||
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return fmt.Errorf("move to trash: %w", err)
|
||||
}
|
||||
@@ -169,11 +235,9 @@ func (s *Service) Open(id string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return openWithSystem(abs)
|
||||
}
|
||||
@@ -190,11 +254,9 @@ func (s *Service) ReadText(id string) (string, error) {
|
||||
if rec.Size > maxPreviewSize {
|
||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
@@ -212,11 +274,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
|
||||
if rec.Size > maxPreviewSize {
|
||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
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.
|
||||
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
|
||||
if err := validateName(filename); err != nil {
|
||||
return nil, fmt.Errorf("invalid filename: %w", err)
|
||||
}
|
||||
filename = s.uniqueTitle(parentID, filename)
|
||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
||||
if err != nil {
|
||||
@@ -247,6 +310,10 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error
|
||||
}
|
||||
f.Close()
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
// Verify dest is inside vault
|
||||
if _, err := s.vaultPath(relPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
|
||||
return node, err
|
||||
}
|
||||
@@ -261,7 +328,7 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||
if original.ParentID != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -271,7 +338,10 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||
if len(records) > 0 {
|
||||
src := &records[0]
|
||||
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)
|
||||
os.MkdirAll(dir, 0o750)
|
||||
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)
|
||||
}
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dst)
|
||||
if _, err := s.vaultPath(relPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -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 ---
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
// Valid sections for root-level nodes.
|
||||
var validSections = map[string]struct{}{
|
||||
"clients": {}, "projects": {}, "recipes": {}, "documents": {}, "archive": {},
|
||||
}
|
||||
|
||||
// Create inserts a root or child node.
|
||||
// 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) {
|
||||
if !IsValidType(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 == "" {
|
||||
return nil, errors.New("title is required")
|
||||
}
|
||||
if section != "" && !IsValidSection(section) {
|
||||
return nil, fmt.Errorf("invalid section: %s", section)
|
||||
}
|
||||
|
||||
n := &Node{
|
||||
ID: util.UUID7(),
|
||||
@@ -87,9 +84,22 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Bump parent's updated_at so it appears in today view.
|
||||
if parentID != "" {
|
||||
_ = r.touch(parentID)
|
||||
}
|
||||
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 {
|
||||
var parent interface{}
|
||||
if n.ParentID != nil {
|
||||
@@ -182,6 +192,41 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
|
||||
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).
|
||||
func (r *Repository) UpdateTitle(id, title string) error {
|
||||
if title == "" {
|
||||
|
||||
@@ -35,12 +35,41 @@ var TypeSet = map[string]struct{}{
|
||||
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.
|
||||
func IsValidType(t string) bool {
|
||||
_, ok := TypeSet[t]
|
||||
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.
|
||||
// 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,
|
||||
6: migration006,
|
||||
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
||||
// 8: migration008, etc.
|
||||
8: migration008,
|
||||
9: migration009,
|
||||
}
|
||||
|
||||
func (db *DB) runInitialSchema() error {
|
||||
|
||||
Reference in New Issue
Block a user