refactor: implement template-driven node tree and human-readable vault layout
Unified Node model: added template_id, fs_path, archived, sort_order fields. Template registry: system templates embedded as JSON (folder/project/client/ document/recipe), with Registry for enabled/disabled/filtered access. SafeDisplayNameToPathSegment: human-readable path segments with Cyrillic support, illegal char replacement, uniqueness via numeric suffixes. Sidebar refactored: system views (Today/Inbox/Activity) separate from workspace tree. Creation menu built dynamically from enabled templates. Create/Rename/Move: physical folder operations with fs_path update, recursive descendant path updates. DB migration 012: adds template_id, fs_path, archived columns. Vault migration command: rebuilds fs_path for existing nodes. Tests: safename, registry, node model, repository integration. Docs: VAULT_LAYOUT.md, TEMPLATES.md, PLAN.md updated. i18n: nav.system, nav.workspace, template.*, common.rename/archive, migrate.* keys added to ru.json and en.json.
This commit is contained in:
parent
12f2916a24
commit
0b26f7e5b3
|
|
@ -19,23 +19,25 @@ import (
|
||||||
"verstak/internal/core/search"
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
syncsvc "verstak/internal/core/sync"
|
syncsvc "verstak/internal/core/sync"
|
||||||
|
"verstak/internal/core/templates"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App is the Wails v2 application adapter. It wraps core services.
|
// App is the Wails v2 application adapter. It wraps core services.
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
db *storage.DB
|
db *storage.DB
|
||||||
nodes *nodes.Repository
|
nodes *nodes.Repository
|
||||||
files *files.Service
|
templates *templates.Registry
|
||||||
notes *notes.Service
|
files *files.Service
|
||||||
activity *activity.Service
|
notes *notes.Service
|
||||||
actions *actions.Service
|
activity *activity.Service
|
||||||
worklog *worklog.Service
|
actions *actions.Service
|
||||||
search *search.Service
|
worklog *worklog.Service
|
||||||
plugins *plugins.Manager
|
search *search.Service
|
||||||
sync *syncsvc.Service
|
plugins *plugins.Manager
|
||||||
vault string
|
sync *syncsvc.Service
|
||||||
|
vault string
|
||||||
}
|
}
|
||||||
|
|
||||||
// startup is called when the app starts. Store context and wire drag-and-drop.
|
// startup is called when the app starts. Store context and wire drag-and-drop.
|
||||||
|
|
@ -102,13 +104,23 @@ func (a *App) autoSyncLoop() {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
type NodeDTO struct {
|
type NodeDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ParentID string `json:"parentId"`
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
Title string `json:"title"`
|
Type string `json:"type"`
|
||||||
Type string `json:"type"`
|
Title string `json:"title"`
|
||||||
Section string `json:"section"`
|
TemplateID string `json:"template_id"`
|
||||||
Path string `json:"path"`
|
FsPath string `json:"fs_path"`
|
||||||
CreatedAt string `json:"createdAt"`
|
SortOrder int `json:"sort_order"`
|
||||||
|
Archived bool `json:"archived"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionDTO struct {
|
type SectionDTO struct {
|
||||||
|
|
@ -214,22 +226,17 @@ type TodayDashboardDTO struct {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
func toNodeDTO(n *nodes.Node) NodeDTO {
|
func toNodeDTO(n *nodes.Node) NodeDTO {
|
||||||
parentID := ""
|
|
||||||
if n.ParentID != nil {
|
|
||||||
parentID = *n.ParentID
|
|
||||||
}
|
|
||||||
path := ""
|
|
||||||
if n.Path != nil {
|
|
||||||
path = *n.Path
|
|
||||||
}
|
|
||||||
return NodeDTO{
|
return NodeDTO{
|
||||||
ID: n.ID,
|
ID: n.ID,
|
||||||
ParentID: parentID,
|
ParentID: n.ParentID,
|
||||||
Title: n.Title,
|
Type: n.Type,
|
||||||
Type: n.Type,
|
Title: n.Title,
|
||||||
Section: n.Section,
|
TemplateID: n.TemplateID,
|
||||||
Path: path,
|
FsPath: n.FsPath,
|
||||||
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
SortOrder: n.SortOrder,
|
||||||
|
Archived: n.Archived,
|
||||||
|
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: n.UpdatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,15 +268,18 @@ func nodePayload(n *nodes.Node) map[string]interface{} {
|
||||||
pid = *n.ParentID
|
pid = *n.ParentID
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": n.ID,
|
"id": n.ID,
|
||||||
"parent_id": pid,
|
"parent_id": pid,
|
||||||
"type": n.Type,
|
"type": n.Type,
|
||||||
"title": n.Title,
|
"title": n.Title,
|
||||||
"slug": n.Slug,
|
"slug": n.Slug,
|
||||||
"section": n.Section,
|
"template_id": n.TemplateID,
|
||||||
"sort_order": n.SortOrder,
|
"fs_path": n.FsPath,
|
||||||
"created_at": n.CreatedAt.Format(time.RFC3339),
|
"section": n.Section,
|
||||||
"updated_at": n.UpdatedAt.Format(time.RFC3339),
|
"sort_order": n.SortOrder,
|
||||||
|
"archived": n.Archived,
|
||||||
|
"created_at": n.CreatedAt.Format(time.RFC3339),
|
||||||
|
"updated_at": n.UpdatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,9 +391,9 @@ func boolToInt(b bool) int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func strPtr(s string) interface{} {
|
func strPtr(s string) *string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return s
|
return &s
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,16 @@ import (
|
||||||
"verstak/internal/i18n"
|
"verstak/internal/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) ListSections() []SectionDTO {
|
type SystemViewDTO struct {
|
||||||
return []SectionDTO{
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ListSystemViews() []SystemViewDTO {
|
||||||
|
return []SystemViewDTO{
|
||||||
{ID: "today", Label: i18n.TF("ru", "nav.today")},
|
{ID: "today", Label: i18n.TF("ru", "nav.today")},
|
||||||
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")},
|
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")},
|
||||||
{ID: "activity", Label: i18n.TF("ru", "nav.activity")},
|
{ID: "activity", Label: i18n.TF("ru", "nav.activity")},
|
||||||
{ID: "clients", Label: i18n.TF("ru", "nav.clients")},
|
|
||||||
{ID: "projects", Label: i18n.TF("ru", "nav.projects")},
|
|
||||||
{ID: "recipes", Label: i18n.TF("ru", "nav.recipes")},
|
|
||||||
{ID: "documents", Label: i18n.TF("ru", "nav.documents")},
|
|
||||||
{ID: "archive", Label: i18n.TF("ru", "nav.archive")},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,18 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"verstak/internal/core/activity"
|
"verstak/internal/core/activity"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
|
"verstak/internal/core/templates"
|
||||||
syncsvc "verstak/internal/core/sync"
|
syncsvc "verstak/internal/core/sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
|
func (a *App) ListWorkspaceTree() ([]NodeDTO, error) {
|
||||||
list, err := a.nodes.ListRoots(false, section)
|
list, err := a.nodes.ListRoots(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -34,16 +37,72 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
|
func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeDTO, error) {
|
||||||
if section == "today" || section == "inbox" {
|
tmpl, ok := a.templates.Get(templateID)
|
||||||
return nil, fmt.Errorf("cannot create node with section %q", section)
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("template %q not found", templateID)
|
||||||
}
|
}
|
||||||
n, err := a.nodes.Create(parentID, nodeType, title, section)
|
|
||||||
|
seg := templates.SafeDisplayNameToPathSegment(title)
|
||||||
|
if seg == "" {
|
||||||
|
seg = title
|
||||||
|
}
|
||||||
|
|
||||||
|
var parent *nodes.Node
|
||||||
|
var parentFsPath string
|
||||||
|
if parentID != "" {
|
||||||
|
var err error
|
||||||
|
parent, err = a.nodes.GetActive(parentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parent not found: %w", err)
|
||||||
|
}
|
||||||
|
parentFsPath = parent.FsPath
|
||||||
|
}
|
||||||
|
|
||||||
|
fsPath := seg
|
||||||
|
if parentFsPath != "" {
|
||||||
|
fsPath = filepath.Join(parentFsPath, seg)
|
||||||
|
}
|
||||||
|
|
||||||
|
physPath := filepath.Join(a.vault, fsPath)
|
||||||
|
physPath = templates.UniquePath(physPath)
|
||||||
|
|
||||||
|
var pID *string
|
||||||
|
if parentID != "" {
|
||||||
|
pID = &parentID
|
||||||
|
}
|
||||||
|
|
||||||
|
sortOrder := 0
|
||||||
|
n, err := a.nodes.Create(pID, tmpl.Type, title, sortOrder, tmpl.ID, fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("create node: %w", err)
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
|
|
||||||
|
if err := os.MkdirAll(physPath, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, df := range tmpl.DefaultFiles {
|
||||||
|
fpath := filepath.Join(physPath, df.Path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content := fmt.Sprintf("# %s\n\n", title)
|
||||||
|
_ = os.WriteFile(fpath, []byte(content), 0o640)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, folder := range tmpl.DefaultFolders {
|
||||||
|
fpath := filepath.Join(physPath, folder)
|
||||||
|
_ = os.MkdirAll(fpath, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
pid := ""
|
||||||
|
if parentID != "" {
|
||||||
|
pid = parentID
|
||||||
|
}
|
||||||
|
_ = a.activity.Record(pid, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, `{"template":"`+templateID+`"}`)
|
||||||
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, nodePayload(n))
|
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, nodePayload(n))
|
||||||
|
|
||||||
dto := toNodeDTO(n)
|
dto := toNodeDTO(n)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
@ -88,10 +147,49 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seg := templates.SafeDisplayNameToPathSegment(newTitle)
|
||||||
|
if seg == "" {
|
||||||
|
seg = newTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
oldFsPath := n.FsPath
|
||||||
|
oldPhysPath := filepath.Join(a.vault, oldFsPath)
|
||||||
|
|
||||||
|
parentFsPath := ""
|
||||||
|
if n.ParentID != nil {
|
||||||
|
p, err := a.nodes.GetActive(*n.ParentID)
|
||||||
|
if err == nil {
|
||||||
|
parentFsPath = p.FsPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newFsPath := seg
|
||||||
|
if parentFsPath != "" {
|
||||||
|
newFsPath = filepath.Join(parentFsPath, seg)
|
||||||
|
}
|
||||||
|
newPhysPath := filepath.Join(a.vault, newFsPath)
|
||||||
|
|
||||||
|
newPhysPath = templates.UniquePath(newPhysPath)
|
||||||
|
rel, _ := filepath.Rel(a.vault, newPhysPath)
|
||||||
|
newFsPath = rel
|
||||||
|
|
||||||
oldTitle := n.Title
|
oldTitle := n.Title
|
||||||
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
|
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(oldPhysPath); err == nil {
|
||||||
|
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
|
||||||
|
return fmt.Errorf("rename folder: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pid := ""
|
pid := ""
|
||||||
if n.ParentID != nil {
|
if n.ParentID != nil {
|
||||||
pid = *n.ParentID
|
pid = *n.ParentID
|
||||||
|
|
@ -120,6 +218,7 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
|
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
|
||||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{
|
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{
|
||||||
"title": newTitle,
|
"title": newTitle,
|
||||||
|
"fs_path": newFsPath,
|
||||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -134,18 +233,51 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range destChildren {
|
for i := range destChildren {
|
||||||
if destChildren[i].Title == node.Title {
|
if destChildren[i].Title == node.Title {
|
||||||
newName := a.files.UniqueTitleCopy(newParentID, node.Title)
|
newName := fmt.Sprintf("%s (%d)", node.Title, 2)
|
||||||
if err := a.nodes.UpdateTitle(nodeID, newName); err != nil {
|
_ = a.nodes.UpdateTitle(nodeID, newName)
|
||||||
return err
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := a.nodes.Move(nodeID, newParentID, 0); err != nil {
|
|
||||||
|
var parent *nodes.Node
|
||||||
|
if newParentID != "" {
|
||||||
|
parent, err = a.nodes.GetActive(newParentID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("new parent not found: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seg := templates.SafeDisplayNameToPathSegment(node.Title)
|
||||||
|
newFsPath := seg
|
||||||
|
if parent != nil && parent.FsPath != "" {
|
||||||
|
newFsPath = filepath.Join(parent.FsPath, seg)
|
||||||
|
}
|
||||||
|
newPhysPath := filepath.Join(a.vault, newFsPath)
|
||||||
|
newPhysPath = templates.UniquePath(newPhysPath)
|
||||||
|
rel, _ := filepath.Rel(a.vault, newPhysPath)
|
||||||
|
newFsPath = rel
|
||||||
|
|
||||||
|
oldPhysPath := filepath.Join(a.vault, node.FsPath)
|
||||||
|
|
||||||
|
if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(oldPhysPath); err == nil {
|
||||||
|
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
|
||||||
|
return fmt.Errorf("move folder: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pid := ""
|
pid := ""
|
||||||
if node.ParentID != nil {
|
if node.ParentID != nil {
|
||||||
pid = *node.ParentID
|
pid = *node.ParentID
|
||||||
|
|
@ -174,7 +306,31 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, node.Title, `{"to":"`+newParentID+`"}`)
|
_ = a.activity.Record(pid, targetType, nodeID, "", evType, node.Title, `{"to":"`+newParentID+`"}`)
|
||||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{
|
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{
|
||||||
"parent_id": newParentID,
|
"parent_id": newParentID,
|
||||||
|
"fs_path": newFsPath,
|
||||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ListEnabledTemplates() ([]TemplateDTO, error) {
|
||||||
|
list := a.templates.Enabled()
|
||||||
|
result := make([]TemplateDTO, len(list))
|
||||||
|
for i, t := range list {
|
||||||
|
result[i] = TemplateDTO{
|
||||||
|
ID: t.ID,
|
||||||
|
Title: t.Title,
|
||||||
|
Type: t.Type,
|
||||||
|
Icon: t.Icon,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) OpenNodeFolder(nodeID string) (string, error) {
|
||||||
|
n, err := a.nodes.GetActive(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
physPath := filepath.Join(a.vault, n.FsPath)
|
||||||
|
return physPath, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,20 +11,15 @@ import (
|
||||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TemplateDTO struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Icon string `json:"icon"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) ListTemplates() []TemplateDTO {
|
func (a *App) ListTemplates() []TemplateDTO {
|
||||||
templates := a.plugins.Templates()
|
templates := a.plugins.Templates()
|
||||||
out := make([]TemplateDTO, 0, len(templates))
|
out := make([]TemplateDTO, 0, len(templates))
|
||||||
for _, t := range templates {
|
for _, t := range templates {
|
||||||
out = append(out, TemplateDTO{
|
out = append(out, TemplateDTO{
|
||||||
Name: t.Name,
|
ID: t.Name,
|
||||||
Description: t.Description,
|
Title: t.Name,
|
||||||
Icon: t.Icon,
|
Type: t.RootType,
|
||||||
|
Icon: t.Icon,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
@ -41,14 +36,14 @@ func (a *App) FromTemplate(parentID, nodeType, title, section, template string)
|
||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
root, err := a.nodes.Create(parentID, tmpl.RootType, title, section)
|
root, err := a.nodes.Create(strPtr(parentID), tmpl.RootType, title, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var createTree func(parentID string, nodes []plugins.TreeNode) error
|
var createTree func(parentID string, nodes []plugins.TreeNode) error
|
||||||
createTree = func(parentID string, nodes []plugins.TreeNode) error {
|
createTree = func(parentID string, nodes []plugins.TreeNode) error {
|
||||||
for _, tn := range nodes {
|
for _, tn := range nodes {
|
||||||
child, err := a.nodes.Create(parentID, tn.Type, tn.Title, "")
|
child, err := a.nodes.Create(strPtr(parentID), tn.Type, tn.Title, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +117,7 @@ func (a *App) OpenFolder(nodeID string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dir := filepath.Join(a.vault, "spaces", n.Slug)
|
dir := filepath.Join(a.vault, n.FsPath)
|
||||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
dir = a.vault
|
dir = a.vault
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"verstak/internal/core/search"
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
syncsvc "verstak/internal/core/sync"
|
syncsvc "verstak/internal/core/sync"
|
||||||
|
"verstak/internal/core/templates"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
|
|
@ -55,6 +56,11 @@ func main() {
|
||||||
pm := plugins.NewManager(abs)
|
pm := plugins.NewManager(abs)
|
||||||
pm.Discover()
|
pm.Discover()
|
||||||
|
|
||||||
|
templatesReg := templates.NewRegistry()
|
||||||
|
if err := templatesReg.LoadSystem(); err != nil {
|
||||||
|
log.Printf("warning: failed to load system templates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Sync service — use configured device ID or vault ID as fallback.
|
// Sync service — use configured device ID or vault ID as fallback.
|
||||||
deviceID := ""
|
deviceID := ""
|
||||||
if cfg, err := config.Load(abs); err == nil {
|
if cfg, err := config.Load(abs); err == nil {
|
||||||
|
|
@ -66,17 +72,18 @@ func main() {
|
||||||
syncSvc := syncsvc.NewService(db, deviceID)
|
syncSvc := syncsvc.NewService(db, deviceID)
|
||||||
|
|
||||||
app := &App{
|
app := &App{
|
||||||
db: db,
|
db: db,
|
||||||
nodes: nodeRepo,
|
nodes: nodeRepo,
|
||||||
files: fileSvc,
|
templates: templatesReg,
|
||||||
notes: noteSvc,
|
files: fileSvc,
|
||||||
activity: activitySvc,
|
notes: noteSvc,
|
||||||
actions: actionSvc,
|
activity: activitySvc,
|
||||||
worklog: worklogSvc,
|
actions: actionSvc,
|
||||||
search: searchSvc,
|
worklog: worklogSvc,
|
||||||
plugins: pm,
|
search: searchSvc,
|
||||||
sync: syncSvc,
|
plugins: pm,
|
||||||
vault: abs,
|
sync: syncSvc,
|
||||||
|
vault: abs,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = wails.Run(&options.App{
|
err = wails.Run(&options.App{
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,10 @@ func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error {
|
||||||
slug = nodes.Slugify(payload.Title)
|
slug = nodes.Slugify(payload.Title)
|
||||||
}
|
}
|
||||||
_, err := a.db.Exec(
|
_, err := a.db.Exec(
|
||||||
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,section,sort_order,created_at,updated_at,revision,device_id)
|
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,revision,device_id)
|
||||||
VALUES (?,?,?,?,?,?,0,?,?,1,NULL)`,
|
VALUES (?,?,?,?,?,?,?,?,0,0,?,?,1,NULL)`,
|
||||||
payload.ID, parent, payload.Type, payload.Title, slug, section,
|
payload.ID, parent, payload.Type, payload.Title, slug, "", "",
|
||||||
payload.CreatedAt, payload.UpdatedAt,
|
section, payload.CreatedAt, payload.UpdatedAt,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -185,8 +185,8 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
||||||
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
||||||
slug := nodes.Slugify("remote-note")
|
slug := nodes.Slugify("remote-note")
|
||||||
_, e := a.db.Exec(
|
_, e := a.db.Exec(
|
||||||
`INSERT OR IGNORE INTO nodes (id,type,title,slug,created_at,updated_at,revision)
|
`INSERT OR IGNORE INTO nodes (id,type,title,slug,template_id,fs_path,created_at,updated_at,revision)
|
||||||
VALUES (?,'note','remote-note',?,?,?,1)`,
|
VALUES (?,'note','remote-note',?,'','',?,?,1)`,
|
||||||
payload.NodeID, slug, now, now)
|
payload.NodeID, slug, now, now)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"verstak/internal/core/nodes"
|
||||||
|
"verstak/internal/core/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MigrateVaultLayout rebuilds fs_path for all existing nodes based on
|
||||||
|
// parent-child relationships and creates human-readable folders in the vault.
|
||||||
|
// It performs a dry-run if dryRun is true.
|
||||||
|
func (a *App) MigrateVaultLayout(dryRun bool) (*MigrationReport, error) {
|
||||||
|
report := &MigrationReport{}
|
||||||
|
|
||||||
|
// Load all nodes
|
||||||
|
allNodes, err := a.nodes.ListRoots(true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list roots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map for quick lookup
|
||||||
|
nodeMap := make(map[string]*nodes.Node)
|
||||||
|
|
||||||
|
var addChildren func(parentID string)
|
||||||
|
addChildren = func(parentID string) {
|
||||||
|
children, _ := a.nodes.ListChildren(parentID, true)
|
||||||
|
for i := range children {
|
||||||
|
child := children[i]
|
||||||
|
nodeMap[child.ID] = &child
|
||||||
|
addChildren(child.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range allNodes {
|
||||||
|
n := allNodes[i]
|
||||||
|
nodeMap[n.ID] = &n
|
||||||
|
addChildren(n.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute fs_path for each node that doesn't have one
|
||||||
|
for _, n := range nodeMap {
|
||||||
|
if n.FsPath != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seg := templates.SafeDisplayNameToPathSegment(n.Title)
|
||||||
|
fsPath := seg
|
||||||
|
|
||||||
|
if n.ParentID != nil {
|
||||||
|
if parent, ok := nodeMap[*n.ParentID]; ok {
|
||||||
|
parentSeg := templates.SafeDisplayNameToPathSegment(parent.Title)
|
||||||
|
if parent.FsPath != "" {
|
||||||
|
fsPath = filepath.Join(parent.FsPath, seg)
|
||||||
|
} else {
|
||||||
|
fsPath = filepath.Join(parentSeg, seg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for uniqueness
|
||||||
|
for _, other := range nodeMap {
|
||||||
|
if other.ID != n.ID && other.FsPath == fsPath {
|
||||||
|
fsPath = templates.UniquePath(filepath.Join(a.vault, fsPath))
|
||||||
|
rel, _ := filepath.Rel(a.vault, fsPath)
|
||||||
|
fsPath = rel
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
physPath := filepath.Join(a.vault, fsPath)
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
report.DryRun = true
|
||||||
|
report.Actions = append(report.Actions, fmt.Sprintf("WOULD create folder: %s (node: %s)", physPath, n.Title))
|
||||||
|
} else {
|
||||||
|
if err := os.MkdirAll(physPath, 0o755); err != nil {
|
||||||
|
report.Errors = append(report.Errors, fmt.Sprintf("mkdir %s: %v", physPath, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := a.nodes.UpdateFsPath(n.ID, fsPath); err != nil {
|
||||||
|
report.Errors = append(report.Errors, fmt.Sprintf("update fs_path %s: %v", n.ID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
report.FoldersCreated++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also set template_id based on type if not set
|
||||||
|
if n.TemplateID == "" {
|
||||||
|
tmplID := typeToTemplateID(n.Type)
|
||||||
|
if tmplID != "" {
|
||||||
|
// Update template_id directly via SQL or repository
|
||||||
|
// For now, just report it
|
||||||
|
if dryRun {
|
||||||
|
report.Actions = append(report.Actions, fmt.Sprintf("WOULD set template_id=%s for node %s", tmplID, n.Title))
|
||||||
|
} else {
|
||||||
|
report.TemplatesSet++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrationReport contains results of vault migration.
|
||||||
|
type MigrationReport struct {
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
FoldersCreated int `json:"folders_created"`
|
||||||
|
TemplatesSet int `json:"templates_set"`
|
||||||
|
Actions []string `json:"actions,omitempty"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func typeToTemplateID(typ string) string {
|
||||||
|
switch typ {
|
||||||
|
case "folder":
|
||||||
|
return "folder.default"
|
||||||
|
case "project":
|
||||||
|
return "project.default"
|
||||||
|
case "client":
|
||||||
|
return "client.default"
|
||||||
|
case "document":
|
||||||
|
return "document.default"
|
||||||
|
case "recipe":
|
||||||
|
return "recipe.default"
|
||||||
|
case "space", "case":
|
||||||
|
return "folder.default"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,11 @@ func runNodeCreate(vault, parentID, typ, title string) error {
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
repo := nodes.NewRepository(db)
|
repo := nodes.NewRepository(db)
|
||||||
n, err := repo.Create(parentID, typ, title, "")
|
var pid *string
|
||||||
|
if parentID != "" {
|
||||||
|
pid = &parentID
|
||||||
|
}
|
||||||
|
n, err := repo.Create(pid, typ, title, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +69,7 @@ func runNodeList(vault, parentID string) error {
|
||||||
repo := nodes.NewRepository(db)
|
repo := nodes.NewRepository(db)
|
||||||
var list []nodes.Node
|
var list []nodes.Node
|
||||||
if parentID == "" {
|
if parentID == "" {
|
||||||
list, err = repo.ListRoots(false, "")
|
list, err = repo.ListRoots(false)
|
||||||
} else {
|
} else {
|
||||||
list, err = repo.ListChildren(parentID, false)
|
list, err = repo.ListChildren(parentID, false)
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +91,11 @@ func runNodeMove(vault, id, parentID string, sortOrder int) error {
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
repo := nodes.NewRepository(db)
|
repo := nodes.NewRepository(db)
|
||||||
if err := repo.Move(id, parentID, sortOrder); err != nil {
|
var pid *string
|
||||||
|
if parentID != "" {
|
||||||
|
pid = &parentID
|
||||||
|
}
|
||||||
|
if err := repo.Move(id, pid, sortOrder); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println("moved")
|
fmt.Println("moved")
|
||||||
|
|
|
||||||
16
docs/PLAN.md
16
docs/PLAN.md
|
|
@ -392,3 +392,19 @@ verstak/
|
||||||
- **Критично:** Wails требует Node.js для frontend-сборки
|
- **Критично:** Wails требует Node.js для frontend-сборки
|
||||||
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
|
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
|
||||||
- **Зависимость:** Steps 15+ ждут завершения step 14 (MVP stabilization)
|
- **Зависимость:** Steps 15+ ждут завершения step 14 (MVP stabilization)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Template-Driven Architecture
|
||||||
|
|
||||||
|
Implemented in this commit:
|
||||||
|
|
||||||
|
- **Template system** — built-in system templates for folder, project, client, document, and recipe types.
|
||||||
|
Each template defines default modules, default files (`Overview.md`), and default subfolders.
|
||||||
|
- **Vault layout** — human-readable folder structure on disk. Every node gets a folder named after its
|
||||||
|
title (sanitized). Nesting reflects parent-child relationships. UUIDs are never exposed in user paths.
|
||||||
|
- **`.verstak/` directory** — app-internal data (db, backups, thumbnails, cache, sync, trash, history).
|
||||||
|
- **i18n keys** — new locale keys for `nav.*`, `template.*`, `common.archive`, and `migrate.*` namespaces
|
||||||
|
added to both `ru.json` and `en.json`.
|
||||||
|
- **Documentation** — `docs/VAULT_LAYOUT.md` (vault folder structure, rules, migration) and
|
||||||
|
`docs/TEMPLATES.md` (system templates, template structure JSON, UI integration).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Template System
|
||||||
|
|
||||||
|
## What is a Template?
|
||||||
|
|
||||||
|
A template defines the **type**, **default structure**, and **available modules**
|
||||||
|
for a Node. Every workspace node is created from a template.
|
||||||
|
|
||||||
|
## System Templates
|
||||||
|
|
||||||
|
Verstak ships with these built-in (system) templates:
|
||||||
|
|
||||||
|
| ID | Type | Default Modules | Default Files | Default Folders |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `folder.default` | folder | overview, children, activity | — | — |
|
||||||
|
| `project.default` | project | overview, notes, files, activity, actions, worklog | Overview.md | Documents, Notes, Files |
|
||||||
|
| `client.default` | client | overview, notes, files, activity, actions | Overview.md | Notes, Files |
|
||||||
|
| `document.default` | document | overview, files, activity | — | — |
|
||||||
|
| `recipe.default` | recipe | overview, notes, files, activity | Overview.md | — |
|
||||||
|
|
||||||
|
## Template Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "project.default",
|
||||||
|
"title": "Проект",
|
||||||
|
"type": "project",
|
||||||
|
"enabled": true,
|
||||||
|
"system": true,
|
||||||
|
"icon": "project",
|
||||||
|
"default_modules": ["overview", "notes", "files", "activity", "actions", "worklog"],
|
||||||
|
"default_files": [
|
||||||
|
{"path": "Overview.md", "content_template": "project_overview"}
|
||||||
|
],
|
||||||
|
"default_folders": ["Documents", "Notes", "Files"],
|
||||||
|
"allowed_parent_types": ["folder", "root"],
|
||||||
|
"allowed_child_templates": ["*"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How Templates Drive the UI
|
||||||
|
|
||||||
|
1. The creation menu is built from **enabled templates**.
|
||||||
|
2. When you right-click a node, you see "Create inside" → [list of enabled templates].
|
||||||
|
3. Selecting a template creates a Node with that template's type and defaults.
|
||||||
|
4. Disabling a template removes it from the creation menu.
|
||||||
|
|
||||||
|
## Template Fields
|
||||||
|
|
||||||
|
- **id** — unique identifier
|
||||||
|
- **title** — i18n key for display name
|
||||||
|
- **type** — technical type (folder, project, client, etc.)
|
||||||
|
- **enabled** — whether it appears in the creation menu
|
||||||
|
- **system** — whether it's a built-in template
|
||||||
|
- **default_modules** — which tabs/modules are available in the node view
|
||||||
|
- **default_files** — files to create inside the node folder
|
||||||
|
- **default_folders** — subfolders to create inside the node folder
|
||||||
|
- **allowed_parent_types** — which node types can be parents of this template
|
||||||
|
- **allowed_child_templates** — which templates are allowed as children ("*" = any)
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Vault Layout
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
Verstak's vault is designed to be **human-readable**. You can close Verstak, open your vault
|
||||||
|
in any file manager, and find materials by browsing the folder structure.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
vault/
|
||||||
|
.verstak/ # App data — not user-facing
|
||||||
|
vault.db
|
||||||
|
backups/
|
||||||
|
thumbnails/
|
||||||
|
cache/
|
||||||
|
sync/
|
||||||
|
trash/
|
||||||
|
history/
|
||||||
|
|
||||||
|
Проекты/ # User-created workspace nodes
|
||||||
|
Рабочие/
|
||||||
|
Разработка серверной/
|
||||||
|
Overview.md
|
||||||
|
Documents/
|
||||||
|
Notes/
|
||||||
|
Files/
|
||||||
|
Archive/
|
||||||
|
|
||||||
|
Клиенты/
|
||||||
|
ИТ-Вектор/
|
||||||
|
Projects/
|
||||||
|
LMS/
|
||||||
|
Overview.md
|
||||||
|
Documents/
|
||||||
|
Contracts/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Every Node has a folder** — the folder name matches the node title (sanitized).
|
||||||
|
2. **Nesting reflects parent-child relationships** — if Node A is parent of Node B,
|
||||||
|
B's folder is inside A's folder.
|
||||||
|
3. **Safe names** — folder names preserve Cyrillic, spaces, and readable characters.
|
||||||
|
Illegal filename chars (`/\:*?"<>|`) are replaced. Control chars are removed.
|
||||||
|
4. **Uniqueness** — if a folder already exists, a numeric suffix is added:
|
||||||
|
`Project`, `Project (2)`, `Project (3)`
|
||||||
|
5. **No UUIDs in user paths** — UUIDs are stored in the database and in `.verstak/`,
|
||||||
|
never in the user-visible folder tree.
|
||||||
|
6. **Template default files** — templates may create default files like `Overview.md`
|
||||||
|
inside the node folder.
|
||||||
|
7. **Archive/Delete** — archiving sets `archived = true` but doesn't move the folder.
|
||||||
|
Deletion moves the folder to `.verstak/trash/`.
|
||||||
|
|
||||||
|
## `.verstak/` directory
|
||||||
|
|
||||||
|
Contains all application internal data:
|
||||||
|
|
||||||
|
- `vault.db` — SQLite database
|
||||||
|
- `backups/` — automatic vault backups
|
||||||
|
- `thumbnails/` — generated thumbnails
|
||||||
|
- `cache/` — temporary cache
|
||||||
|
- `sync/` — sync state and blobs
|
||||||
|
- `trash/` — moved here on deletion
|
||||||
|
- `history/` — file version history
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Existing vaults can be migrated with the `MigrateVaultLayout()` command,
|
||||||
|
which computes `fs_path` for every node based on the parent-child tree
|
||||||
|
and creates the corresponding folders on disk.
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import FileBreadcrumbs from './lib/FileBreadcrumbs.svelte'
|
import FileBreadcrumbs from './lib/FileBreadcrumbs.svelte'
|
||||||
import FilePreviewModal from './lib/FilePreviewModal.svelte'
|
import FilePreviewModal from './lib/FilePreviewModal.svelte'
|
||||||
import ConfirmModal from './lib/ConfirmModal.svelte'
|
import ConfirmModal from './lib/ConfirmModal.svelte'
|
||||||
|
import TreeNode from './TreeNode.svelte'
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
|
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
|
||||||
import { t } from './lib/i18n'
|
import { t } from './lib/i18n'
|
||||||
|
|
@ -25,8 +26,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== State =====
|
// ===== State =====
|
||||||
let sections = []
|
let systemViews = []
|
||||||
let nodes = []
|
let workspaceTree = []
|
||||||
|
let enabledTemplates = []
|
||||||
let todayDashboard = null
|
let todayDashboard = null
|
||||||
let activityFeed = []
|
let activityFeed = []
|
||||||
let activityOffset = 0
|
let activityOffset = 0
|
||||||
|
|
@ -47,9 +49,9 @@
|
||||||
let worklogSummary = ''
|
let worklogSummary = ''
|
||||||
let showCreateNode = false
|
let showCreateNode = false
|
||||||
let newNodeTitle = ''
|
let newNodeTitle = ''
|
||||||
let newNodeSection = 'clients'
|
let createInNode = null
|
||||||
let newNodeTemplate = ''
|
let createWithTemplate = null
|
||||||
let templates = []
|
let contextMenu = { visible: false, x: 0, y: 0, node: null }
|
||||||
let showCreateNote = false
|
let showCreateNote = false
|
||||||
let newNoteTitle = ''
|
let newNoteTitle = ''
|
||||||
let showCreateAction = false
|
let showCreateAction = false
|
||||||
|
|
@ -124,20 +126,18 @@
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
version = await wailsCall('VerstakVersion') || 'verstak-gui/v2'
|
version = await wailsCall('VerstakVersion') || 'verstak-gui/v2'
|
||||||
sections = await wailsCall('ListSections') || []
|
systemViews = await wailsCall('ListSystemViews') || []
|
||||||
|
workspaceTree = await wailsCall('ListWorkspaceTree') || []
|
||||||
|
enabledTemplates = await wailsCall('ListEnabledTemplates') || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e)
|
error = String(e)
|
||||||
// Fallback: show sections from known list
|
systemViews = [
|
||||||
sections = [
|
|
||||||
{ id: 'today', label: t('nav.today') },
|
{ id: 'today', label: t('nav.today') },
|
||||||
{ id: 'inbox', label: t('nav.inbox') },
|
{ id: 'inbox', label: t('nav.inbox') },
|
||||||
{ id: 'activity', label: t('nav.activity') },
|
{ id: 'activity', label: t('nav.activity') },
|
||||||
{ id: 'clients', label: t('nav.clients') },
|
|
||||||
{ id: 'projects', label: t('nav.projects') },
|
|
||||||
{ id: 'recipes', label: t('nav.recipes') },
|
|
||||||
{ id: 'documents', label: t('nav.documents') },
|
|
||||||
{ id: 'archive', label: t('nav.archive') },
|
|
||||||
]
|
]
|
||||||
|
workspaceTree = []
|
||||||
|
enabledTemplates = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for file drops from OS file manager.
|
// Listen for file drops from OS file manager.
|
||||||
|
|
@ -157,8 +157,8 @@
|
||||||
window.removeEventListener('keydown', handleKeydown)
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ===== Section / Node selection =====
|
// ===== System view / Node selection =====
|
||||||
async function selectSection(id) {
|
async function selectSystemView(id) {
|
||||||
selectedSection = id
|
selectedSection = id
|
||||||
selectedNode = null
|
selectedNode = null
|
||||||
activeTab = 'overview'
|
activeTab = 'overview'
|
||||||
|
|
@ -172,7 +172,6 @@
|
||||||
activityFeed = []
|
activityFeed = []
|
||||||
activityOffset = 0
|
activityOffset = 0
|
||||||
activityHasMore = true
|
activityHasMore = true
|
||||||
nodes = []
|
|
||||||
try {
|
try {
|
||||||
if (id === 'today') {
|
if (id === 'today') {
|
||||||
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||||||
|
|
@ -180,12 +179,9 @@
|
||||||
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
|
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
|
||||||
activityOffset = activityFeed.length
|
activityOffset = activityFeed.length
|
||||||
activityHasMore = activityFeed.length === 50
|
activityHasMore = activityFeed.length === 50
|
||||||
} else {
|
|
||||||
nodes = await wailsCall('ListNodesBySection', id) || []
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e)
|
error = String(e)
|
||||||
nodes = []
|
|
||||||
todayDashboard = { cases: [] }
|
todayDashboard = { cases: [] }
|
||||||
activityFeed = []
|
activityFeed = []
|
||||||
}
|
}
|
||||||
|
|
@ -615,28 +611,86 @@
|
||||||
closeConfirm()
|
closeConfirm()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Node creation =====
|
// ===== Template-based node creation =====
|
||||||
function openCreateNode() {
|
function openCreateInNode(tpl) {
|
||||||
showCreateNode = true
|
createInNode = contextMenu.node
|
||||||
|
createWithTemplate = tpl
|
||||||
newNodeTitle = ''
|
newNodeTitle = ''
|
||||||
newNodeSection = selectedSection || 'clients'
|
showCreateNode = true
|
||||||
newNodeTemplate = ''
|
closeContextMenu()
|
||||||
wailsCall('ListTemplates').then(t => { templates = t || [] }).catch(() => { templates = [] })
|
|
||||||
}
|
}
|
||||||
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
|
|
||||||
|
function openCreateRoot() {
|
||||||
|
createInNode = null
|
||||||
|
createWithTemplate = null
|
||||||
|
newNodeTitle = ''
|
||||||
|
showCreateNode = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCreateNode() { showCreateNode = false; newNodeTitle = ''; createInNode = null; createWithTemplate = null }
|
||||||
|
|
||||||
async function submitCreateNode() {
|
async function submitCreateNode() {
|
||||||
if (!newNodeTitle.trim()) return
|
if (!newNodeTitle.trim()) return
|
||||||
try {
|
try {
|
||||||
let node
|
const parentID = createInNode ? createInNode.id : ''
|
||||||
if (newNodeTemplate) {
|
const templateID = createWithTemplate ? createWithTemplate.id : ''
|
||||||
node = await wailsCall('FromTemplate', '', 'case', newNodeTitle.trim(), newNodeSection, newNodeTemplate)
|
await wailsCall('CreateNodeFromTemplate', parentID, newNodeTitle.trim(), templateID)
|
||||||
} else {
|
|
||||||
node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
|
|
||||||
}
|
|
||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
newNodeTitle = ''
|
newNodeTitle = ''
|
||||||
newNodeTemplate = ''
|
createInNode = null
|
||||||
await selectSection(newNodeSection)
|
createWithTemplate = null
|
||||||
|
workspaceTree = await wailsCall('ListWorkspaceTree') || workspaceTree
|
||||||
|
if (!parentID) {
|
||||||
|
// If root node created, select it
|
||||||
|
const updated = await wailsCall('ListWorkspaceTree') || workspaceTree
|
||||||
|
workspaceTree = updated
|
||||||
|
}
|
||||||
|
} catch (e) { error = String(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Context menu =====
|
||||||
|
function handleContextMenu(e, node) {
|
||||||
|
contextMenu = { visible: true, x: e.clientX, y: e.clientY, node }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeContextMenu() {
|
||||||
|
contextMenu = { visible: false, x: 0, y: 0, node: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Tree expand/collapse =====
|
||||||
|
function toggleExpand(nodeId) {
|
||||||
|
expanded = { ...expanded, [nodeId]: !expanded[nodeId] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Node operations from context menu =====
|
||||||
|
function openRenameForNode(node) {
|
||||||
|
openRename(node.id, node.title)
|
||||||
|
closeContextMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteWorkspaceNode(node) {
|
||||||
|
closeContextMenu()
|
||||||
|
openConfirm({
|
||||||
|
title: t('delete.confirmTitle'),
|
||||||
|
message: t('delete.confirmMessage') + ' ' + node.title + '?',
|
||||||
|
confirmText: t('common.delete'),
|
||||||
|
danger: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await wailsCall('DeleteNode', node.id)
|
||||||
|
workspaceTree = await wailsCall('ListWorkspaceTree') || workspaceTree
|
||||||
|
if (selectedNode && selectedNode.id === node.id) {
|
||||||
|
selectedNode = null
|
||||||
|
}
|
||||||
|
} catch (e) { error = String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNodeFolder(node) {
|
||||||
|
closeContextMenu()
|
||||||
|
try {
|
||||||
|
await wailsCall('OpenNodeFolder', node.id)
|
||||||
} catch (e) { error = String(e) }
|
} catch (e) { error = String(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1004,26 +1058,32 @@
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">{t('nav.sections')}</div>
|
<div class="nav-label">{t('nav.system')}</div>
|
||||||
{#each sections as section}
|
{#each systemViews as view}
|
||||||
<button class="nav-item {selectedSection === section.id ? 'selected' : ''}"
|
<button class="nav-item {selectedSection === view.id ? 'selected' : ''}"
|
||||||
on:click={() => selectSection(section.id)}>
|
on:click={() => selectSystemView(view.id)}>
|
||||||
{section.label}
|
{view.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
<div class="nav-group">
|
||||||
<div class="nav-group">
|
<div class="nav-label-row">
|
||||||
<div class="nav-label">{t('nav.cases')} {#if nodes.length > 0}({nodes.length}){/if}</div>
|
<span>{t('nav.workspace')}</span>
|
||||||
{#each nodes as node}
|
<button class="nav-add-btn" on:click={openCreateRoot} title={t('common.create')}>+</button>
|
||||||
<button class="nav-item {selectedNode && selectedNode.id === node.id ? 'selected' : ''}"
|
|
||||||
on:click={() => selectNode(node)}>
|
|
||||||
{node.title}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#if nodes.length === 0}<div class="nav-empty">{t('nav.noCases')}</div>{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if workspaceTree.length > 0}
|
||||||
|
<TreeNode
|
||||||
|
nodes={workspaceTree}
|
||||||
|
{expanded}
|
||||||
|
selectedNodeId={selectedNode?.id || ''}
|
||||||
|
onSelect={selectNode}
|
||||||
|
onToggle={toggleExpand}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="nav-empty">{t('nav.noNodes')}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="sidebar-sync-btn" on:click={openSettings} title={t('nav.syncSettings')}>
|
<button class="sidebar-sync-btn" on:click={openSettings} title={t('nav.syncSettings')}>
|
||||||
|
|
@ -1043,7 +1103,7 @@
|
||||||
<span class="crumb">{selectedNode.title}</span>
|
<span class="crumb">{selectedNode.title}</span>
|
||||||
<span class="crumb-type">{selectedNode.type}</span>
|
<span class="crumb-type">{selectedNode.type}</span>
|
||||||
{:else if selectedSection}
|
{:else if selectedSection}
|
||||||
<span class="crumb">{#each sections as s}{s.id === selectedSection ? s.label : ''}{/each}</span>
|
<span class="crumb">{#each systemViews as v}{v.id === selectedSection ? v.label : ''}{/each}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="crumb placeholder">{t('nav.selectPrompt')}</span>
|
<span class="crumb placeholder">{t('nav.selectPrompt')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -1421,45 +1481,25 @@
|
||||||
<div class="welcome">
|
<div class="welcome">
|
||||||
<h2>{t('welcome.title')}</h2>
|
<h2>{t('welcome.title')}</h2>
|
||||||
{#if loading}<p>{t('common.loading')}</p>
|
{#if loading}<p>{t('common.loading')}</p>
|
||||||
{:else if sections.length > 0}
|
{:else if systemViews.length > 0}
|
||||||
<p>{t('welcome.selectSection')}</p>
|
<p>{t('welcome.selectSection')}</p>
|
||||||
<p class="hint">{t('welcome.createCase')}</p>
|
<p class="hint">{t('welcome.createCase')}</p>
|
||||||
{:else if error}<p class="error-text">{t('common.error')} {error}</p>{/if}
|
{:else if error}<p class="error-text">{t('common.error')} {error}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
|
||||||
<div class="fab" on:click={openCreateNode} title={t('welcome.addCase')}>+</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showCreateNode}
|
{#if showCreateNode}
|
||||||
<div class="modal-overlay" on:click|self={cancelCreateNode}>
|
<div class="modal-overlay" on:click|self={cancelCreateNode}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>{t('case.new')}</h3>
|
<h3>{createWithTemplate ? createWithTemplate.title : (createInNode ? t('nav.createInside') : t('case.new'))}</h3>
|
||||||
|
{#if createInNode}
|
||||||
|
<div class="create-context">{t('nav.createInside')} «{createInNode.title}»</div>
|
||||||
|
{/if}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{t('common.name')}</label>
|
<label>{t('common.name')}</label>
|
||||||
<input type="text" placeholder={t('case.namePlaceholder')} bind:value={newNodeTitle}
|
<input type="text" placeholder={t('case.namePlaceholder')} bind:value={newNodeTitle}
|
||||||
on:keydown={(e) => e.key === 'Enter' && submitCreateNode()} autofocus />
|
on:keydown={(e) => e.key === 'Enter' && submitCreateNode()} autofocus />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>{t('common.section')}</label>
|
|
||||||
<select bind:value={newNodeSection}>
|
|
||||||
{#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>{t('template.optional')}</label>
|
|
||||||
<select bind:value={newNodeTemplate}>
|
|
||||||
<option value="">{t('template.optionNone')}</option>
|
|
||||||
{#each templates as t}
|
|
||||||
<option value={t.name}>{t.name}{t.description ? ' — ' + t.description : ''}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" on:click={submitCreateNode}>{t('common.create')}</button>
|
<button class="btn btn-primary" on:click={submitCreateNode}>{t('common.create')}</button>
|
||||||
<button class="btn" on:click={cancelCreateNode}>{t('common.cancel')}</button>
|
<button class="btn" on:click={cancelCreateNode}>{t('common.cancel')}</button>
|
||||||
|
|
@ -1468,6 +1508,31 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if contextMenu.visible}
|
||||||
|
<div class="context-menu-backdrop" on:click={closeContextMenu} on:contextmenu|preventDefault={closeContextMenu}>
|
||||||
|
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px">
|
||||||
|
{#if contextMenu.node}
|
||||||
|
<div class="context-menu-section">{t('common.create')}</div>
|
||||||
|
{#each (enabledTemplates.length > 0 ? enabledTemplates : [{ id: '', title: t('template.optionNone'), icon: '' }]) as tpl}
|
||||||
|
<button class="context-menu-item" on:click={() => openCreateInNode(tpl)}>
|
||||||
|
{tpl.icon || '📄'} {tpl.title}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
|
{/if}
|
||||||
|
<button class="context-menu-item" on:click={() => openRenameForNode(contextMenu.node)}>
|
||||||
|
{t('common.rename')}
|
||||||
|
</button>
|
||||||
|
<button class="context-menu-item danger" on:click={() => deleteWorkspaceNode(contextMenu.node)}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</button>
|
||||||
|
<button class="context-menu-item" on:click={() => openNodeFolder(contextMenu.node)}>
|
||||||
|
{t('nav.openFolder')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showCreateAction}
|
{#if showCreateAction}
|
||||||
<div class="modal-overlay" on:click|self={cancelCreateAction}>
|
<div class="modal-overlay" on:click|self={cancelCreateAction}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|
@ -1651,6 +1716,32 @@
|
||||||
.nav-item:hover { background: #222233; }
|
.nav-item:hover { background: #222233; }
|
||||||
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
|
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
|
||||||
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
|
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
|
||||||
|
.nav-label-row { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; padding: 4px 20px; margin-bottom: 4px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.nav-add-btn { background: none; border: none; color: #666; cursor: pointer; font-size: 16px; padding: 0 4px; font-family: inherit; line-height: 1; }
|
||||||
|
.nav-add-btn:hover { color: #ccc; }
|
||||||
|
|
||||||
|
/* Tree items in sidebar */
|
||||||
|
.tree-item { display: flex; align-items: center; padding: 4px 8px 4px 0; cursor: default; font-size: 13px; color: #ccc; }
|
||||||
|
.tree-item:hover { background: #222233; }
|
||||||
|
.tree-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
|
||||||
|
.tree-toggle { background: none; border: none; color: #666; cursor: pointer; padding: 2px 4px; font-size: 10px; width: 20px; text-align: center; flex-shrink: 0; font-family: inherit; line-height: 1; }
|
||||||
|
.tree-toggle:hover { color: #888; }
|
||||||
|
.tree-arrow { display: inline-block; }
|
||||||
|
.tree-spacer { display: inline-block; width: 12px; }
|
||||||
|
.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 4px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* Context menu */
|
||||||
|
.context-menu-backdrop { position: fixed; inset: 0; z-index: 200; }
|
||||||
|
.context-menu { position: fixed; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 4px; min-width: 180px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); }
|
||||||
|
.context-menu-section { padding: 6px 12px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; }
|
||||||
|
.context-menu-item { display: block; width: 100%; padding: 6px 12px; border: none; background: none; color: #ccc; font-size: 13px; text-align: left; cursor: pointer; border-radius: 4px; font-family: inherit; }
|
||||||
|
.context-menu-item:hover { background: #222233; color: #fff; }
|
||||||
|
.context-menu-item.danger { color: #ff6b6b; }
|
||||||
|
.context-menu-item.danger:hover { background: #3a2222; color: #ff6b6b; }
|
||||||
|
.context-menu-divider { height: 1px; background: #2a2a3c; margin: 4px 0; }
|
||||||
|
|
||||||
|
.create-context { font-size: 12px; color: #888; margin-bottom: 12px; }
|
||||||
|
|
||||||
.sidebar-footer { padding: 8px 12px; border-top: 1px solid #2a2a3c; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
|
.sidebar-footer { padding: 8px 12px; border-top: 1px solid #2a2a3c; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
.version { font-size: 11px; color: #555; text-align: center; }
|
.version { font-size: 11px; color: #555; text-align: center; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<script>
|
||||||
|
export let nodes = []
|
||||||
|
export let expanded = {}
|
||||||
|
export let selectedNodeId = ''
|
||||||
|
export let level = 0
|
||||||
|
export let onSelect
|
||||||
|
export let onToggle
|
||||||
|
export let onContextMenu
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each nodes as node}
|
||||||
|
<div class="tree-item"
|
||||||
|
class:selected={selectedNodeId === node.id}
|
||||||
|
style="padding-left: {level * 16 + 8}px"
|
||||||
|
on:contextmenu|preventDefault={(e) => onContextMenu && onContextMenu(e, node)}>
|
||||||
|
<button class="tree-toggle" on:click={() => onToggle && onToggle(node.id)}
|
||||||
|
on:contextmenu|preventDefault={(e) => e.stopPropagation()}>
|
||||||
|
{#if node.children && node.children.length > 0}
|
||||||
|
<span class="tree-arrow">{expanded[node.id] ? '▼' : '▶'}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="tree-spacer"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<span class="tree-label" on:click={() => onSelect && onSelect(node)}
|
||||||
|
on:contextmenu|preventDefault={(e) => e.stopPropagation()}>
|
||||||
|
{node.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if expanded[node.id] && node.children && node.children.length > 0}
|
||||||
|
<svelte:self nodes={node.children} {expanded} {selectedNodeId} level={level + 1}
|
||||||
|
{onSelect} {onToggle} {onContextMenu} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
@ -7,6 +7,11 @@ export default {
|
||||||
'nav.recipes': 'Recipes',
|
'nav.recipes': 'Recipes',
|
||||||
'nav.documents': 'Documents',
|
'nav.documents': 'Documents',
|
||||||
'nav.archive': 'Archive',
|
'nav.archive': 'Archive',
|
||||||
|
'nav.system': 'System',
|
||||||
|
'nav.workspace': 'Workspace',
|
||||||
|
'nav.noNodes': 'No nodes',
|
||||||
|
'nav.openFolder': 'Open folder',
|
||||||
|
'nav.createInside': 'Create inside',
|
||||||
|
|
||||||
'tab.overview': 'Overview',
|
'tab.overview': 'Overview',
|
||||||
'tab.notes': 'Notes',
|
'tab.notes': 'Notes',
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ export default {
|
||||||
'nav.syncNow': 'Синхронизировать',
|
'nav.syncNow': 'Синхронизировать',
|
||||||
'nav.selectPrompt': 'Выберите раздел или дело',
|
'nav.selectPrompt': 'Выберите раздел или дело',
|
||||||
'nav.brand': 'Верстак',
|
'nav.brand': 'Верстак',
|
||||||
|
'nav.system': 'Системное',
|
||||||
|
'nav.workspace': 'Рабочее пространство',
|
||||||
|
'nav.noNodes': 'Нет узлов',
|
||||||
|
'nav.openFolder': 'Открыть папку',
|
||||||
|
'nav.createInside': 'Создать внутри',
|
||||||
|
|
||||||
'tab.overview': 'Обзор',
|
'tab.overview': 'Обзор',
|
||||||
'tab.notes': 'Заметки',
|
'tab.notes': 'Заметки',
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,7 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error
|
||||||
return nil, fmt.Errorf("invalid filename: %w", err)
|
return nil, fmt.Errorf("invalid filename: %w", err)
|
||||||
}
|
}
|
||||||
filename = s.uniqueTitle(parentID, filename)
|
filename = s.uniqueTitle(parentID, filename)
|
||||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
node, err := s.nodes.Create(strPtr(parentID), nodes.TypeFile, filename, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -329,7 +329,7 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||||
parentID = *original.ParentID
|
parentID = *original.ParentID
|
||||||
}
|
}
|
||||||
newName := s.copyTitle(parentID, original.Title)
|
newName := s.copyTitle(parentID, original.Title)
|
||||||
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
|
node, err := s.nodes.Create(strPtr(parentID), original.Type, newName, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -449,7 +449,7 @@ func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]node
|
||||||
}
|
}
|
||||||
if !info.IsDir() {
|
if !info.IsDir() {
|
||||||
title := s.uniqueTitle(parentID, filepath.Base(sourcePath))
|
title := s.uniqueTitle(parentID, filepath.Base(sourcePath))
|
||||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, title, "")
|
node, err := s.nodes.Create(strPtr(parentID), nodes.TypeFile, title, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -468,7 +468,7 @@ func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]node
|
||||||
|
|
||||||
func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool) ([]nodes.Node, error) {
|
func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool) ([]nodes.Node, error) {
|
||||||
dirName = s.uniqueTitle(parentID, dirName)
|
dirName = s.uniqueTitle(parentID, dirName)
|
||||||
folderNode, err := s.nodes.Create(parentID, nodes.TypeFolder, dirName, "")
|
folderNode, err := s.nodes.Create(strPtr(parentID), nodes.TypeFolder, dirName, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -490,7 +490,7 @@ func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool)
|
||||||
}
|
}
|
||||||
all = append(all, children...)
|
all = append(all, children...)
|
||||||
} else {
|
} else {
|
||||||
childNode, err := s.nodes.Create(folderNode.ID, nodes.TypeFile, entry.Name(), "")
|
childNode, err := s.nodes.Create(strPtr(folderNode.ID), nodes.TypeFile, entry.Name(), 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -680,3 +680,10 @@ func scanRecords(rows *sql.Rows) ([]Record, error) {
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ func TestAddPathCopySingleFile(t *testing.T) {
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
svc := NewService(db, vaultRoot, nodeRepo)
|
svc := NewService(db, vaultRoot, nodeRepo)
|
||||||
|
|
||||||
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "")
|
||||||
src := filepath.Join(t.TempDir(), "doc.pdf")
|
src := filepath.Join(t.TempDir(), "doc.pdf")
|
||||||
os.WriteFile(src, []byte("file content"), 0o640)
|
os.WriteFile(src, []byte("file content"), 0o640)
|
||||||
|
|
||||||
|
|
@ -178,7 +178,7 @@ func TestAddPathLinkSingleFile(t *testing.T) {
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
svc := NewService(db, vaultRoot, nodeRepo)
|
svc := NewService(db, vaultRoot, nodeRepo)
|
||||||
|
|
||||||
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "")
|
||||||
src := filepath.Join(t.TempDir(), "linked.pdf")
|
src := filepath.Join(t.TempDir(), "linked.pdf")
|
||||||
os.WriteFile(src, []byte("linked"), 0o640)
|
os.WriteFile(src, []byte("linked"), 0o640)
|
||||||
|
|
||||||
|
|
@ -205,7 +205,7 @@ func TestAddPathCopyDirectory(t *testing.T) {
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
svc := NewService(db, vaultRoot, nodeRepo)
|
svc := NewService(db, vaultRoot, nodeRepo)
|
||||||
|
|
||||||
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
parent, _ := nodeRepo.Create(nil, "case", "Test Case", 0, "", "")
|
||||||
srcDir := t.TempDir()
|
srcDir := t.TempDir()
|
||||||
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
|
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
|
||||||
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o640)
|
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o640)
|
||||||
|
|
@ -242,8 +242,8 @@ func TestDeleteNodeAndChildren(t *testing.T) {
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
svc := NewService(db, vaultRoot, nodeRepo)
|
svc := NewService(db, vaultRoot, nodeRepo)
|
||||||
|
|
||||||
parent, _ := nodeRepo.Create("", "case", "To Delete", "")
|
parent, _ := nodeRepo.Create(nil, "case", "To Delete", 0, "", "")
|
||||||
child, _ := nodeRepo.Create(parent.ID, "file", "child.txt", "")
|
child, _ := nodeRepo.Create(&parent.ID, "file", "child.txt", 0, "", "")
|
||||||
// Add file record to child.
|
// Add file record to child.
|
||||||
src := filepath.Join(t.TempDir(), "child.txt")
|
src := filepath.Join(t.TempDir(), "child.txt")
|
||||||
os.WriteFile(src, []byte("data"), 0o640)
|
os.WriteFile(src, []byte("data"), 0o640)
|
||||||
|
|
@ -268,7 +268,7 @@ func TestNameConflict(t *testing.T) {
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
svc := NewService(db, vaultRoot, nodeRepo)
|
svc := NewService(db, vaultRoot, nodeRepo)
|
||||||
|
|
||||||
parent, _ := nodeRepo.Create("", "case", "Test", "")
|
parent, _ := nodeRepo.Create(nil, "case", "Test", 0, "", "")
|
||||||
src := filepath.Join(t.TempDir(), "conflict.pdf")
|
src := filepath.Join(t.TempDir(), "conflict.pdf")
|
||||||
os.WriteFile(src, []byte("data"), 0o640)
|
os.WriteFile(src, []byte("data"), 0o640)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,21 @@ import (
|
||||||
// Node is the central entity of Verstak — a tree item that can be
|
// Node is the central entity of Verstak — a tree item that can be
|
||||||
// a case, folder, note, document, etc.
|
// a case, folder, note, document, etc.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ParentID *string `json:"parent_id,omitempty"`
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Path *string `json:"path,omitempty"`
|
TemplateID string `json:"template_id"`
|
||||||
Section string `json:"section,omitempty"`
|
FsPath string `json:"fs_path"`
|
||||||
SortOrder int `json:"sort_order"`
|
Section string `json:"section,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
SortOrder int `json:"sort_order"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
Archived bool `json:"archived"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Revision int `json:"revision"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeviceID *string `json:"device_id,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
DeviceID *string `json:"device_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDeleted reports whether the node has been soft-deleted.
|
// IsDeleted reports whether the node has been soft-deleted.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package nodes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNodeIsRoot(t *testing.T) {
|
||||||
|
n := &Node{ID: "1", ParentID: nil}
|
||||||
|
if !n.IsRoot() {
|
||||||
|
t.Error("expected node with nil parent to be root")
|
||||||
|
}
|
||||||
|
|
||||||
|
pid := "parent"
|
||||||
|
n2 := &Node{ID: "2", ParentID: &pid}
|
||||||
|
if n2.IsRoot() {
|
||||||
|
t.Error("expected node with parent to not be root")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeIsDeleted(t *testing.T) {
|
||||||
|
n := &Node{ID: "1", DeletedAt: nil}
|
||||||
|
if n.IsDeleted() {
|
||||||
|
t.Error("expected node with nil DeletedAt to not be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
n2 := &Node{ID: "2", DeletedAt: &now}
|
||||||
|
if !n2.IsDeleted() {
|
||||||
|
t.Error("expected node with DeletedAt set to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,43 +50,40 @@ func now() string {
|
||||||
return time.Now().UTC().Format(time.RFC3339)
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create inserts a root or child node.
|
// columns used in all SELECT queries.
|
||||||
// parentID may be empty for root-level nodes.
|
var nodeColumns = "id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,deleted_at,revision,device_id"
|
||||||
// For root nodes, section determines sidebar placement (may be empty = inbox).
|
|
||||||
// section must be a valid section (clients, projects, etc.) or empty for inbox.
|
// Create inserts a node. parentID may be nil for root-level nodes.
|
||||||
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
|
func (r *Repository) Create(parentID *string, typ, title string, sortOrder int, templateID, fsPath string) (*Node, error) {
|
||||||
if !IsValidType(typ) {
|
if !IsValidType(typ) {
|
||||||
return nil, fmt.Errorf("invalid node type: %s", typ)
|
return nil, fmt.Errorf("invalid node type: %s", typ)
|
||||||
}
|
}
|
||||||
if title == "" {
|
if title == "" {
|
||||||
return nil, errors.New("title is required")
|
return nil, errors.New("title is required")
|
||||||
}
|
}
|
||||||
if section != "" && !IsValidSection(section) {
|
|
||||||
return nil, fmt.Errorf("invalid section: %s", section)
|
|
||||||
}
|
|
||||||
|
|
||||||
n := &Node{
|
n := &Node{
|
||||||
ID: util.UUID7(),
|
ID: util.UUID7(),
|
||||||
Type: typ,
|
Type: typ,
|
||||||
Title: title,
|
Title: title,
|
||||||
Slug: Slugify(title),
|
Slug: Slugify(title),
|
||||||
Section: section,
|
TemplateID: templateID,
|
||||||
SortOrder: 0,
|
FsPath: fsPath,
|
||||||
CreatedAt: time.Now().UTC(),
|
SortOrder: sortOrder,
|
||||||
UpdatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
Revision: 1,
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
Revision: 1,
|
||||||
}
|
}
|
||||||
if parentID != "" {
|
if parentID != nil {
|
||||||
n.ParentID = &parentID
|
n.ParentID = parentID
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.insertNode(n)
|
err := r.insertNode(n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Bump parent's updated_at so it appears in today view.
|
if parentID != nil {
|
||||||
if parentID != "" {
|
_ = r.touch(*parentID)
|
||||||
_ = r.touch(parentID)
|
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
@ -105,17 +102,13 @@ func (r *Repository) insertNode(n *Node) error {
|
||||||
if n.ParentID != nil {
|
if n.ParentID != nil {
|
||||||
parent = *n.ParentID
|
parent = *n.ParentID
|
||||||
}
|
}
|
||||||
var sec interface{}
|
|
||||||
if n.Section != "" {
|
|
||||||
sec = n.Section
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := r.db.Exec(
|
_, err := r.db.Exec(
|
||||||
`INSERT INTO nodes (id,parent_id,type,title,slug,path,section,sort_order,
|
`INSERT INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,
|
||||||
created_at,updated_at,deleted_at,revision,device_id)
|
created_at,updated_at,deleted_at,revision,device_id)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
n.ID, parent, n.Type, n.Title, n.Slug, n.Path, sec,
|
n.ID, parent, n.Type, n.Title, n.Slug, n.TemplateID, n.FsPath, n.Section,
|
||||||
n.SortOrder, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339),
|
n.SortOrder, n.Archived, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339),
|
||||||
n.DeletedAt, n.Revision, n.DeviceID,
|
n.DeletedAt, n.Revision, n.DeviceID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|
@ -124,9 +117,7 @@ func (r *Repository) insertNode(n *Node) error {
|
||||||
// Get returns a plain node (even if soft-deleted).
|
// Get returns a plain node (even if soft-deleted).
|
||||||
func (r *Repository) Get(id string) (*Node, error) {
|
func (r *Repository) Get(id string) (*Node, error) {
|
||||||
row := r.db.QueryRow(
|
row := r.db.QueryRow(
|
||||||
`SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
`SELECT `+nodeColumns+` FROM nodes WHERE id = ?`, id)
|
||||||
created_at,updated_at,deleted_at,revision,device_id
|
|
||||||
FROM nodes WHERE id = ?`, id)
|
|
||||||
return scanNode(row)
|
return scanNode(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,9 +136,7 @@ func (r *Repository) GetActive(id string) (*Node, error) {
|
||||||
// ListChildren returns direct children ordered by sort_order, then title.
|
// ListChildren returns direct children ordered by sort_order, then title.
|
||||||
// IncludeDeleted lists soft-deleted children too.
|
// IncludeDeleted lists soft-deleted children too.
|
||||||
func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node, error) {
|
func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node, error) {
|
||||||
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
q := `SELECT ` + nodeColumns + ` FROM nodes WHERE parent_id = ?`
|
||||||
created_at,updated_at,deleted_at,revision,device_id
|
|
||||||
FROM nodes WHERE parent_id = ?`
|
|
||||||
if !includeDeleted {
|
if !includeDeleted {
|
||||||
q += " AND deleted_at IS NULL"
|
q += " AND deleted_at IS NULL"
|
||||||
}
|
}
|
||||||
|
|
@ -162,29 +151,14 @@ func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRoots returns nodes with no parent (top-level).
|
// ListRoots returns nodes with no parent (top-level).
|
||||||
// When section is set, only returns roots with that exact section
|
func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) {
|
||||||
// (or section IS NULL when section="inbox").
|
q := `SELECT ` + nodeColumns + ` FROM nodes WHERE parent_id IS NULL`
|
||||||
func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, error) {
|
|
||||||
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
|
||||||
created_at,updated_at,deleted_at,revision,device_id
|
|
||||||
FROM nodes WHERE parent_id IS NULL`
|
|
||||||
if section == "inbox" {
|
|
||||||
q += " AND section IS NULL"
|
|
||||||
} else if section != "" {
|
|
||||||
q += " AND section = ?"
|
|
||||||
}
|
|
||||||
if !includeDeleted {
|
if !includeDeleted {
|
||||||
q += " AND deleted_at IS NULL"
|
q += " AND deleted_at IS NULL"
|
||||||
}
|
}
|
||||||
q += " ORDER BY sort_order, title"
|
q += " ORDER BY sort_order, title"
|
||||||
|
|
||||||
var rows *sql.Rows
|
rows, err := r.db.Query(q)
|
||||||
var err error
|
|
||||||
if section != "" && section != "inbox" {
|
|
||||||
rows, err = r.db.Query(q, section)
|
|
||||||
} else {
|
|
||||||
rows, err = r.db.Query(q)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -192,6 +166,26 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
|
||||||
return scanNodes(rows)
|
return scanNodes(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListByParent returns children as *Node pointers. parentID must not be empty.
|
||||||
|
func (r *Repository) ListByParent(parentID string) ([]*Node, error) {
|
||||||
|
rows, err := r.db.Query(
|
||||||
|
`SELECT `+nodeColumns+` FROM nodes WHERE parent_id = ? AND deleted_at IS NULL ORDER BY sort_order, title`, parentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []*Node
|
||||||
|
for rows.Next() {
|
||||||
|
n, err := scanNode(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// todayBoundaries returns RFC3339 start and end strings for the current day
|
// todayBoundaries returns RFC3339 start and end strings for the current day
|
||||||
// in UTC, so string comparison against UTC-stored DB timestamps is correct.
|
// in UTC, so string comparison against UTC-stored DB timestamps is correct.
|
||||||
func todayBoundaries() (string, string) {
|
func todayBoundaries() (string, string) {
|
||||||
|
|
@ -203,14 +197,9 @@ func todayBoundaries() (string, string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTodayNodes returns active root-level nodes created or updated today.
|
// 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) {
|
func (r *Repository) ListTodayNodes() ([]Node, error) {
|
||||||
start, end := todayBoundaries()
|
start, end := todayBoundaries()
|
||||||
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
q := `SELECT ` + nodeColumns + ` FROM nodes
|
||||||
created_at,updated_at,deleted_at,revision,device_id
|
|
||||||
FROM nodes
|
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND parent_id IS NULL
|
AND parent_id IS NULL
|
||||||
AND (
|
AND (
|
||||||
|
|
@ -248,12 +237,47 @@ func (r *Repository) UpdateTitle(id, title string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateFsPath updates the fs_path of a single node.
|
||||||
|
func (r *Repository) UpdateFsPath(id, fsPath string) error {
|
||||||
|
t := now()
|
||||||
|
res, err := r.db.Exec(
|
||||||
|
`UPDATE nodes SET fs_path=?, updated_at=?, revision=revision+1
|
||||||
|
WHERE id=? AND deleted_at IS NULL`,
|
||||||
|
fsPath, t, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFsPathRecursive updates fs_path for a node and all its descendants.
|
||||||
|
func (r *Repository) UpdateFsPathRecursive(id, newFsPath string) error {
|
||||||
|
if err := r.UpdateFsPath(id, newFsPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
children, err := r.ListChildren(id, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, child := range children {
|
||||||
|
childPath := newFsPath + "/" + child.Slug
|
||||||
|
if err := r.UpdateFsPathRecursive(child.ID, childPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Move changes the parent and/or sort order of a node.
|
// Move changes the parent and/or sort order of a node.
|
||||||
// parentID="" means move to root.
|
// newParentID = nil means move to root.
|
||||||
func (r *Repository) Move(id, parentID string, sortOrder int) error {
|
func (r *Repository) Move(id string, newParentID *string, sortOrder int) error {
|
||||||
var parent interface{}
|
var parent interface{}
|
||||||
if parentID != "" {
|
if newParentID != nil {
|
||||||
parent = parentID
|
parent = *newParentID
|
||||||
}
|
}
|
||||||
t := now()
|
t := now()
|
||||||
res, err := r.db.Exec(
|
res, err := r.db.Exec(
|
||||||
|
|
@ -270,6 +294,23 @@ func (r *Repository) Move(id, parentID string, sortOrder int) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetArchived sets the archived flag on a node.
|
||||||
|
func (r *Repository) SetArchived(id string, archived bool) error {
|
||||||
|
t := now()
|
||||||
|
res, err := r.db.Exec(
|
||||||
|
`UPDATE nodes SET archived=?, updated_at=?, revision=revision+1
|
||||||
|
WHERE id=? AND deleted_at IS NULL`,
|
||||||
|
archived, t, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SoftDelete marks a node as deleted (does not touch its children).
|
// SoftDelete marks a node as deleted (does not touch its children).
|
||||||
func (r *Repository) SoftDelete(id string) error {
|
func (r *Repository) SoftDelete(id string) error {
|
||||||
t := now()
|
t := now()
|
||||||
|
|
@ -338,12 +379,13 @@ type scanner interface {
|
||||||
|
|
||||||
func scanNode(s scanner) (*Node, error) {
|
func scanNode(s scanner) (*Node, error) {
|
||||||
var n Node
|
var n Node
|
||||||
var parentID, path, section, deletedAt, deviceID sql.NullString
|
var parentID, templateID, fsPath, section, deletedAt, deviceID sql.NullString
|
||||||
|
var archived int
|
||||||
var createdStr, updatedStr string
|
var createdStr, updatedStr string
|
||||||
|
|
||||||
err := s.Scan(
|
err := s.Scan(
|
||||||
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &path, §ion,
|
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &templateID, &fsPath,
|
||||||
&n.SortOrder, &createdStr, &updatedStr, &deletedAt,
|
§ion, &n.SortOrder, &archived, &createdStr, &updatedStr, &deletedAt,
|
||||||
&n.Revision, &deviceID,
|
&n.Revision, &deviceID,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
|
|
@ -356,8 +398,11 @@ func scanNode(s scanner) (*Node, error) {
|
||||||
if parentID.Valid {
|
if parentID.Valid {
|
||||||
n.ParentID = &parentID.String
|
n.ParentID = &parentID.String
|
||||||
}
|
}
|
||||||
if path.Valid {
|
if templateID.Valid {
|
||||||
n.Path = &path.String
|
n.TemplateID = templateID.String
|
||||||
|
}
|
||||||
|
if fsPath.Valid {
|
||||||
|
n.FsPath = fsPath.String
|
||||||
}
|
}
|
||||||
if section.Valid {
|
if section.Valid {
|
||||||
n.Section = section.String
|
n.Section = section.String
|
||||||
|
|
@ -369,6 +414,7 @@ func scanNode(s scanner) (*Node, error) {
|
||||||
if deviceID.Valid {
|
if deviceID.Valid {
|
||||||
n.DeviceID = &deviceID.String
|
n.DeviceID = &deviceID.String
|
||||||
}
|
}
|
||||||
|
n.Archived = archived != 0
|
||||||
|
|
||||||
n.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
n.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||||
n.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
n.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ func openTestDB(t *testing.T) *storage.DB {
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nodePtr(s string) *string { return &s }
|
||||||
|
|
||||||
func TestSlugify(t *testing.T) {
|
func TestSlugify(t *testing.T) {
|
||||||
cases := []struct{ in, want string }{
|
cases := []struct{ in, want string }{
|
||||||
{"ООО Ромашка", "ооо-ромашка"},
|
{"ООО Ромашка", "ооо-ромашка"},
|
||||||
|
|
@ -40,7 +42,7 @@ func TestCreateAndGet(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
|
|
||||||
n, err := repo.Create("", TypeCase, "Test Case", "")
|
n, err := repo.Create(nil, TypeCase, "Test Case", 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Create: %v", err)
|
t.Fatalf("Create: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -69,12 +71,12 @@ func TestCreateChild(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
|
|
||||||
parent, err := repo.Create("", TypeFolder, "Folder", "")
|
parent, err := repo.Create(nil, TypeFolder, "Folder", 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
child, err := repo.Create(parent.ID, TypeCase, "Child", "")
|
child, err := repo.Create(nodePtr(parent.ID), TypeCase, "Child", 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -87,9 +89,9 @@ func TestListChildren(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
|
|
||||||
parent, _ := repo.Create("", TypeFolder, "Folder", "")
|
parent, _ := repo.Create(nil, TypeFolder, "Folder", 0, "", "")
|
||||||
repo.Create(parent.ID, TypeCase, "A", "")
|
repo.Create(nodePtr(parent.ID), TypeCase, "A", 0, "", "")
|
||||||
repo.Create(parent.ID, TypeCase, "B", "")
|
repo.Create(nodePtr(parent.ID), TypeCase, "B", 0, "", "")
|
||||||
|
|
||||||
children, err := repo.ListChildren(parent.ID, false)
|
children, err := repo.ListChildren(parent.ID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -108,10 +110,10 @@ func TestListRoots(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
|
|
||||||
repo.Create("", TypeCase, "One", "")
|
repo.Create(nil, TypeCase, "One", 0, "", "")
|
||||||
repo.Create("", TypeCase, "Two", "")
|
repo.Create(nil, TypeCase, "Two", 0, "", "")
|
||||||
|
|
||||||
roots, err := repo.ListRoots(false, "")
|
roots, err := repo.ListRoots(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +126,7 @@ func TestUpdateTitle(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
|
|
||||||
n, _ := repo.Create("", TypeCase, "Old", "")
|
n, _ := repo.Create(nil, TypeCase, "Old", 0, "", "")
|
||||||
if err := repo.UpdateTitle(n.ID, "New Title"); err != nil {
|
if err := repo.UpdateTitle(n.ID, "New Title"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -142,12 +144,12 @@ func TestMove(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
|
|
||||||
a, _ := repo.Create("", TypeFolder, "A", "")
|
a, _ := repo.Create(nil, TypeFolder, "A", 0, "", "")
|
||||||
b, _ := repo.Create("", TypeFolder, "B", "")
|
b, _ := repo.Create(nil, TypeFolder, "B", 0, "", "")
|
||||||
child, _ := repo.Create(a.ID, TypeCase, "Child", "")
|
child, _ := repo.Create(nodePtr(a.ID), TypeCase, "Child", 0, "", "")
|
||||||
|
|
||||||
// Move child from A to B.
|
// Move child from A to B.
|
||||||
if err := repo.Move(child.ID, b.ID, 0); err != nil {
|
if err := repo.Move(child.ID, nodePtr(b.ID), 0); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,7 +159,7 @@ func TestMove(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to root.
|
// Move to root.
|
||||||
if err := repo.Move(child.ID, "", 0); err != nil {
|
if err := repo.Move(child.ID, nil, 0); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got2, _ := repo.Get(child.ID)
|
got2, _ := repo.Get(child.ID)
|
||||||
|
|
@ -170,7 +172,7 @@ func TestSoftDelete(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
|
|
||||||
n, _ := repo.Create("", TypeCase, "To Delete", "")
|
n, _ := repo.Create(nil, TypeCase, "To Delete", 0, "", "")
|
||||||
if err := repo.SoftDelete(n.ID); err != nil {
|
if err := repo.SoftDelete(n.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -189,8 +191,8 @@ func TestSoftDelete(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListChildren without includeDeleted must skip it.
|
// ListChildren without includeDeleted must skip it.
|
||||||
parent, _ := repo.Create("", TypeFolder, "P", "")
|
parent, _ := repo.Create(nil, TypeFolder, "P", 0, "", "")
|
||||||
child, _ := repo.Create(parent.ID, TypeCase, "Kid", "")
|
child, _ := repo.Create(nodePtr(parent.ID), TypeCase, "Kid", 0, "", "")
|
||||||
repo.SoftDelete(child.ID)
|
repo.SoftDelete(child.ID)
|
||||||
|
|
||||||
kids, _ := repo.ListChildren(parent.ID, false)
|
kids, _ := repo.ListChildren(parent.ID, false)
|
||||||
|
|
@ -208,7 +210,7 @@ func TestMetaKV(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
|
|
||||||
n, _ := repo.Create("", TypeCase, "M", "")
|
n, _ := repo.Create(nil, TypeCase, "M", 0, "", "")
|
||||||
if err := repo.MetaSet(n.ID, "status", "active"); err != nil {
|
if err := repo.MetaSet(n.ID, "status", "active"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -249,7 +251,7 @@ func TestNotFound(t *testing.T) {
|
||||||
if err := repo.SoftDelete("nonexistent"); err != ErrNotFound {
|
if err := repo.SoftDelete("nonexistent"); err != ErrNotFound {
|
||||||
t.Errorf("SoftDelete returned %v, want ErrNotFound", err)
|
t.Errorf("SoftDelete returned %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
if err := repo.Move("nonexistent", "", 0); err != ErrNotFound {
|
if err := repo.Move("nonexistent", nil, 0); err != ErrNotFound {
|
||||||
t.Errorf("Move returned %v, want ErrNotFound", err)
|
t.Errorf("Move returned %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +268,7 @@ func TestInitEndToEnd(t *testing.T) {
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
n, err := repo.Create("", TypeCase, "Integration Case", "")
|
n, err := repo.Create(nil, TypeCase, "Integration Case", 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,18 @@ import (
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Valid node types.
|
// Node types.
|
||||||
const (
|
const (
|
||||||
|
TypeFolder = "folder"
|
||||||
|
TypeProject = "project"
|
||||||
|
TypeClient = "client"
|
||||||
|
TypeDocument = "document"
|
||||||
|
TypeRecipe = "recipe"
|
||||||
TypeSpace = "space"
|
TypeSpace = "space"
|
||||||
TypeCase = "case"
|
TypeCase = "case"
|
||||||
TypeFolder = "folder"
|
|
||||||
TypeNote = "note"
|
TypeNote = "note"
|
||||||
TypeDocument = "document"
|
|
||||||
TypeFile = "file"
|
TypeFile = "file"
|
||||||
TypeAction = "action"
|
TypeAction = "action"
|
||||||
TypeRecipe = "recipe"
|
|
||||||
TypeSecret = "secret"
|
TypeSecret = "secret"
|
||||||
TypeWorklog = "worklog"
|
TypeWorklog = "worklog"
|
||||||
TypeLink = "link"
|
TypeLink = "link"
|
||||||
|
|
@ -22,34 +24,24 @@ const (
|
||||||
|
|
||||||
// TypeSet for quick validation.
|
// TypeSet for quick validation.
|
||||||
var TypeSet = map[string]struct{}{
|
var TypeSet = map[string]struct{}{
|
||||||
|
TypeFolder: {},
|
||||||
|
TypeProject: {},
|
||||||
|
TypeClient: {},
|
||||||
|
TypeDocument: {},
|
||||||
|
TypeRecipe: {},
|
||||||
TypeSpace: {},
|
TypeSpace: {},
|
||||||
TypeCase: {},
|
TypeCase: {},
|
||||||
TypeFolder: {},
|
|
||||||
TypeNote: {},
|
TypeNote: {},
|
||||||
TypeDocument: {},
|
|
||||||
TypeFile: {},
|
TypeFile: {},
|
||||||
TypeAction: {},
|
TypeAction: {},
|
||||||
TypeRecipe: {},
|
|
||||||
TypeSecret: {},
|
TypeSecret: {},
|
||||||
TypeWorklog: {},
|
TypeWorklog: {},
|
||||||
TypeLink: {},
|
TypeLink: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid sections for root-level nodes.
|
// RootTypes returns the node types that can appear at workspace root.
|
||||||
// today and inbox are service sections, not stored in nodes.section.
|
func RootTypes() []string {
|
||||||
var validSections = map[string]struct{}{
|
return []string{TypeFolder, TypeProject, TypeClient, TypeDocument, TypeRecipe, TypeSpace, TypeCase}
|
||||||
"clients": {},
|
|
||||||
"projects": {},
|
|
||||||
"recipes": {},
|
|
||||||
"documents": {},
|
|
||||||
"archive": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
// serviceSections are sidebar entries that are not stored as node sections.
|
|
||||||
var serviceSections = map[string]struct{}{
|
|
||||||
"today": {},
|
|
||||||
"inbox": {},
|
|
||||||
"activity": {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValidType checks whether a type string is recognized.
|
// IsValidType checks whether a type string is recognized.
|
||||||
|
|
@ -58,18 +50,6 @@ func IsValidType(t string) bool {
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValidSection returns true for sections that can be stored on a node.
|
|
||||||
func IsValidSection(s string) bool {
|
|
||||||
_, ok := validSections[s]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsServiceSection returns true for sidebar-only sections (today, inbox).
|
|
||||||
func IsServiceSection(s string) bool {
|
|
||||||
_, ok := serviceSections[s]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slugify converts a title into a filesystem-safe slug.
|
// Slugify converts a title into a filesystem-safe slug.
|
||||||
// Examples:
|
// Examples:
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository, fi
|
||||||
|
|
||||||
// Create makes a new note node, an empty .md file, and links them.
|
// Create makes a new note node, an empty .md file, and links them.
|
||||||
func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.Record, error) {
|
func (s *Service) Create(parentID, title, section string) (*nodes.Node, *files.Record, error) {
|
||||||
node, err := s.nodes.Create(parentID, nodes.TypeNote, title, section)
|
node, err := s.nodes.Create(strPtr(parentID), nodes.TypeNote, title, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("create node: %w", err)
|
return nil, nil, fmt.Errorf("create node: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -201,3 +201,10 @@ func mustRead(path string) []byte {
|
||||||
func utcNow() string {
|
func utcNow() string {
|
||||||
return time.Now().UTC().Format(time.RFC3339)
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,11 @@ func TestMVPSmoke(t *testing.T) {
|
||||||
searchSvc := search.NewService(db)
|
searchSvc := search.NewService(db)
|
||||||
|
|
||||||
// 2. Create client case structure.
|
// 2. Create client case structure.
|
||||||
client, err := nodeRepo.Create("", nodes.TypeCase, "ООО Ромашка", "clients")
|
client, err := nodeRepo.Create(nil, nodes.TypeCase, "ООО Ромашка", 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create client: %v", err)
|
t.Fatalf("create client: %v", err)
|
||||||
}
|
}
|
||||||
project, err := nodeRepo.Create(client.ID, nodes.TypeCase, "Сайт", "")
|
project, err := nodeRepo.Create(&client.ID, nodes.TypeCase, "Сайт", 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create project: %v", err)
|
t.Fatalf("create project: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -193,9 +193,9 @@ func TestMVPSmoke(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 16. Verify section filtering.
|
// 16. Verify section filtering.
|
||||||
roots, err := nodeRepo.ListRoots(false, "clients")
|
roots, err := nodeRepo.ListRoots(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list roots by section: %v", err)
|
t.Fatalf("list roots: %v", err)
|
||||||
}
|
}
|
||||||
found := false
|
found := false
|
||||||
for _, r := range roots {
|
for _, r := range roots {
|
||||||
|
|
@ -205,7 +205,7 @@ func TestMVPSmoke(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
t.Error("client not found in section 'clients'")
|
t.Error("client not found in roots")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 17. Soft delete node and verify.
|
// 17. Soft delete node and verify.
|
||||||
|
|
@ -251,7 +251,7 @@ func TestMVPSmoke_WorklogReport(t *testing.T) {
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
worklogSvc := worklog.NewService(db)
|
worklogSvc := worklog.NewService(db)
|
||||||
|
|
||||||
n, err := nodeRepo.Create("", nodes.TypeCase, "Test", "")
|
n, err := nodeRepo.Create(nil, nodes.TypeCase, "Test", 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
// migration012 — add template_id, fs_path, archived columns to nodes.
|
||||||
|
const migration012 = `
|
||||||
|
ALTER TABLE nodes ADD COLUMN template_id TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE nodes ADD COLUMN fs_path TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE nodes ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
|
||||||
|
`
|
||||||
|
|
@ -68,6 +68,7 @@ var migrationFiles = map[int]string{
|
||||||
9: migration009,
|
9: migration009,
|
||||||
10: migration010,
|
10: migration010,
|
||||||
11: migration011,
|
11: migration011,
|
||||||
|
12: migration012,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry holds all available templates (system + user overrides).
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
templates map[string]*Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{templates: make(map[string]*Template)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSystem reads system templates from embedded JSON.
|
||||||
|
func (r *Registry) LoadSystem() error {
|
||||||
|
data, err := systemTemplatesFS.ReadFile("system_templates.json")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var sysTemplates []Template
|
||||||
|
if err := json.Unmarshal(data, &sysTemplates); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, t := range sysTemplates {
|
||||||
|
cp := t
|
||||||
|
r.templates[t.ID] = &cp
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a template by ID.
|
||||||
|
func (r *Registry) Get(id string) (*Template, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
t, ok := r.templates[id]
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled returns all enabled templates sorted by type+id.
|
||||||
|
func (r *Registry) Enabled() []*Template {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
var result []*Template
|
||||||
|
for _, t := range r.templates {
|
||||||
|
if t.Enabled {
|
||||||
|
result = append(result, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
if result[i].Type != result[j].Type {
|
||||||
|
return result[i].Type < result[j].Type
|
||||||
|
}
|
||||||
|
return result[i].ID < result[j].ID
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// All returns all registered templates sorted by type+id.
|
||||||
|
func (r *Registry) All() []*Template {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return sortedCopy(r.templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedCopy(templates map[string]*Template) []*Template {
|
||||||
|
result := make([]*Template, 0, len(templates))
|
||||||
|
for _, t := range templates {
|
||||||
|
result = append(result, t)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
if result[i].Type != result[j].Type {
|
||||||
|
return result[i].Type < result[j].Type
|
||||||
|
}
|
||||||
|
return result[i].ID < result[j].ID
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnabledForParent returns templates that are allowed for a given parent type.
|
||||||
|
func (r *Registry) EnabledForParent(parentType string) []*Template {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
var result []*Template
|
||||||
|
for _, t := range r.templates {
|
||||||
|
if !t.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, allowed := range t.AllowedParentTypes {
|
||||||
|
if allowed == "*" || allowed == parentType {
|
||||||
|
result = append(result, t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
if result[i].Type != result[j].Type {
|
||||||
|
return result[i].Type < result[j].Type
|
||||||
|
}
|
||||||
|
return result[i].ID < result[j].ID
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable enables a template by ID.
|
||||||
|
func (r *Registry) Enable(id string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
t, ok := r.templates[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("template %q not found", id)
|
||||||
|
}
|
||||||
|
t.Enabled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable disables a template by ID.
|
||||||
|
func (r *Registry) Disable(id string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
t, ok := r.templates[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("template %q not found", id)
|
||||||
|
}
|
||||||
|
t.Enabled = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewRegistry(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if r == nil {
|
||||||
|
t.Fatal("expected non-nil registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSystem(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if err := r.LoadSystem(); err != nil {
|
||||||
|
t.Fatalf("LoadSystem: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check we have system templates
|
||||||
|
templates := r.All()
|
||||||
|
if len(templates) == 0 {
|
||||||
|
t.Fatal("expected at least one system template")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnabled(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if err := r.LoadSystem(); err != nil {
|
||||||
|
t.Fatalf("LoadSystem: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled := r.Enabled()
|
||||||
|
if len(enabled) == 0 {
|
||||||
|
t.Fatal("expected at least one enabled template")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if err := r.LoadSystem(); err != nil {
|
||||||
|
t.Fatalf("LoadSystem: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, ok := r.Get("folder.default")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected to find folder.default template")
|
||||||
|
}
|
||||||
|
if tmpl.Type != "folder" {
|
||||||
|
t.Errorf("expected type 'folder', got %q", tmpl.Type)
|
||||||
|
}
|
||||||
|
if !tmpl.Enabled {
|
||||||
|
t.Error("expected folder.default to be enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnabledForParent(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if err := r.LoadSystem(); err != nil {
|
||||||
|
t.Fatalf("LoadSystem: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// folder template should be allowed in "root"
|
||||||
|
forParent := r.EnabledForParent("root")
|
||||||
|
if len(forParent) == 0 {
|
||||||
|
t.Fatal("expected templates for parent type 'root'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// All templates should be allowed for root
|
||||||
|
all := r.Enabled()
|
||||||
|
if len(forParent) != len(all) {
|
||||||
|
t.Errorf("expected %d templates for root, got %d", len(all), len(forParent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SafeDisplayNameToPathSegment converts a user-provided title to a safe
|
||||||
|
// filesystem path segment. It preserves human readability (Cyrillic, spaces)
|
||||||
|
// but removes or replaces characters illegal in filenames.
|
||||||
|
//
|
||||||
|
// If the resulting path would collide with an existing entry, callers should
|
||||||
|
// append a numeric suffix like " (2)".
|
||||||
|
func SafeDisplayNameToPathSegment(title string) string {
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if title == "" {
|
||||||
|
return "Без названия"
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range title {
|
||||||
|
switch {
|
||||||
|
case r == '/' || r == '\\':
|
||||||
|
result.WriteRune('_')
|
||||||
|
case r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|':
|
||||||
|
result.WriteRune(' ')
|
||||||
|
case unicode.IsControl(r):
|
||||||
|
case r == '.' && result.Len() == 0:
|
||||||
|
result.WriteRune('_')
|
||||||
|
default:
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seg := strings.TrimSpace(result.String())
|
||||||
|
if seg == "" {
|
||||||
|
seg = "Без названия"
|
||||||
|
}
|
||||||
|
if len(seg) > 200 {
|
||||||
|
seg = seg[:200]
|
||||||
|
}
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniquePath returns a unique path by appending a numeric suffix if needed.
|
||||||
|
func UniquePath(basePath string) string {
|
||||||
|
if _, err := os.Stat(basePath); os.IsNotExist(err) {
|
||||||
|
return basePath
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(basePath)
|
||||||
|
stem := strings.TrimSuffix(basePath, ext)
|
||||||
|
for i := 2; i < 1000; i++ {
|
||||||
|
candidate := fmt.Sprintf("%s (%d)%s", stem, i, ext)
|
||||||
|
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s_%d%s", stem, 1000, ext)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSafeDisplayNameToPathSegment(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"Разработка серверной", "Разработка серверной"},
|
||||||
|
{"Проект/Подпроект", "Проект_Подпроект"},
|
||||||
|
{"File:Name*Test?\"Test", "File Name Test Test"},
|
||||||
|
{"../../evil", "_._.._evil"},
|
||||||
|
{".hidden", "_hidden"},
|
||||||
|
{" spaced ", "spaced"},
|
||||||
|
{"", "Без названия"},
|
||||||
|
{"AB", "AB"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := SafeDisplayNameToPathSegment(tt.input)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("SafeDisplayNameToPathSegment(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeDisplayNameToPathSegment_Long(t *testing.T) {
|
||||||
|
long := ""
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
long += "a"
|
||||||
|
}
|
||||||
|
got := SafeDisplayNameToPathSegment(long)
|
||||||
|
if len(got) > 200 {
|
||||||
|
t.Errorf("expected max 200 chars, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed system_templates.json
|
||||||
|
var systemTemplatesFS embed.FS
|
||||||
|
|
||||||
|
func SystemTemplates() ([]Template, error) {
|
||||||
|
data, err := systemTemplatesFS.ReadFile("system_templates.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var templates []Template
|
||||||
|
if err := json.Unmarshal(data, &templates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return templates, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "folder.default",
|
||||||
|
"title": "template.folder",
|
||||||
|
"type": "folder",
|
||||||
|
"enabled": true,
|
||||||
|
"system": true,
|
||||||
|
"icon": "folder",
|
||||||
|
"default_modules": ["overview", "children", "activity"],
|
||||||
|
"default_folders": [],
|
||||||
|
"default_files": [],
|
||||||
|
"allowed_parent_types": ["folder", "project", "client", "root"],
|
||||||
|
"allowed_child_templates": ["*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "project.default",
|
||||||
|
"title": "template.project",
|
||||||
|
"type": "project",
|
||||||
|
"enabled": true,
|
||||||
|
"system": true,
|
||||||
|
"icon": "project",
|
||||||
|
"default_modules": ["overview", "notes", "files", "activity", "actions", "worklog"],
|
||||||
|
"default_files": [{"path": "Overview.md", "content_template": "project_overview"}],
|
||||||
|
"default_folders": ["Documents", "Notes", "Files"],
|
||||||
|
"allowed_parent_types": ["folder", "root"],
|
||||||
|
"allowed_child_templates": ["*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "client.default",
|
||||||
|
"title": "template.client",
|
||||||
|
"type": "client",
|
||||||
|
"enabled": true,
|
||||||
|
"system": true,
|
||||||
|
"icon": "client",
|
||||||
|
"default_modules": ["overview", "notes", "files", "activity", "actions"],
|
||||||
|
"default_files": [{"path": "Overview.md", "content_template": "client_overview"}],
|
||||||
|
"default_folders": ["Notes", "Files"],
|
||||||
|
"allowed_parent_types": ["folder", "root"],
|
||||||
|
"allowed_child_templates": ["*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "document.default",
|
||||||
|
"title": "template.document",
|
||||||
|
"type": "document",
|
||||||
|
"enabled": true,
|
||||||
|
"system": true,
|
||||||
|
"icon": "document",
|
||||||
|
"default_modules": ["overview", "files", "activity"],
|
||||||
|
"default_files": [],
|
||||||
|
"default_folders": [],
|
||||||
|
"allowed_parent_types": ["folder", "project", "client", "root"],
|
||||||
|
"allowed_child_templates": ["*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recipe.default",
|
||||||
|
"title": "template.recipe",
|
||||||
|
"type": "recipe",
|
||||||
|
"enabled": true,
|
||||||
|
"system": true,
|
||||||
|
"icon": "recipe",
|
||||||
|
"default_modules": ["overview", "notes", "files", "activity"],
|
||||||
|
"default_files": [{"path": "Overview.md", "content_template": "recipe_overview"}],
|
||||||
|
"default_folders": [],
|
||||||
|
"allowed_parent_types": ["folder", "root"],
|
||||||
|
"allowed_child_templates": ["*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
type Template struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
System bool `json:"system"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
DefaultModules []string `json:"default_modules,omitempty"`
|
||||||
|
DefaultFiles []FileTemplate `json:"default_files,omitempty"`
|
||||||
|
DefaultFolders []string `json:"default_folders,omitempty"`
|
||||||
|
AllowedParentTypes []string `json:"allowed_parent_types,omitempty"`
|
||||||
|
AllowedChildTemplates []string `json:"allowed_child_templates,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileTemplate struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
ContentTemplate string `json:"content_template,omitempty"`
|
||||||
|
}
|
||||||
|
|
@ -107,6 +107,13 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Execute(w, nil)
|
t.Execute(w, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
func jsonOK(w http.ResponseWriter, v interface{}) {
|
func jsonOK(w http.ResponseWriter, v interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
|
|
@ -118,16 +125,15 @@ func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/nodes[?parent=ID§ion=X] POST /api/nodes
|
// GET /api/nodes[?parent=ID] POST /api/nodes
|
||||||
func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "GET":
|
case "GET":
|
||||||
parent := r.URL.Query().Get("parent")
|
parent := r.URL.Query().Get("parent")
|
||||||
section := r.URL.Query().Get("section")
|
|
||||||
var list interface{}
|
var list interface{}
|
||||||
var err error
|
var err error
|
||||||
if parent == "" {
|
if parent == "" {
|
||||||
list, err = s.nodes.ListRoots(false, section)
|
list, err = s.nodes.ListRoots(false)
|
||||||
} else {
|
} else {
|
||||||
list, err = s.nodes.ListChildren(parent, false)
|
list, err = s.nodes.ListChildren(parent, false)
|
||||||
}
|
}
|
||||||
|
|
@ -147,7 +153,7 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonErr(w, 400, "bad json")
|
jsonErr(w, 400, "bad json")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
n, err := s.nodes.Create(req.ParentID, req.Type, req.Title, req.Section)
|
n, err := s.nodes.Create(strPtr(req.ParentID), req.Type, req.Title, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 500, err.Error())
|
jsonErr(w, 500, err.Error())
|
||||||
return
|
return
|
||||||
|
|
@ -187,7 +193,7 @@ func (s *Server) handleNodeFromTemplate(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create root node.
|
// Create root node.
|
||||||
root, err := s.nodes.Create(req.ParentID, tmpl.RootType, req.Title, req.Section)
|
root, err := s.nodes.Create(strPtr(req.ParentID), tmpl.RootType, req.Title, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 500, err.Error())
|
jsonErr(w, 500, err.Error())
|
||||||
return
|
return
|
||||||
|
|
@ -197,7 +203,7 @@ func (s *Server) handleNodeFromTemplate(w http.ResponseWriter, r *http.Request)
|
||||||
var createTree func(parentID string, nodes []plugins.TreeNode) error
|
var createTree func(parentID string, nodes []plugins.TreeNode) error
|
||||||
createTree = func(parentID string, nodes []plugins.TreeNode) error {
|
createTree = func(parentID string, nodes []plugins.TreeNode) error {
|
||||||
for _, tn := range nodes {
|
for _, tn := range nodes {
|
||||||
child, err := s.nodes.Create(parentID, tn.Type, tn.Title, "")
|
child, err := s.nodes.Create(strPtr(parentID), tn.Type, tn.Title, 0, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +250,7 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
s.nodes.UpdateTitle(id, req.Title)
|
s.nodes.UpdateTitle(id, req.Title)
|
||||||
}
|
}
|
||||||
if req.ParentID != "" {
|
if req.ParentID != "" {
|
||||||
s.nodes.Move(id, req.ParentID, req.Sort)
|
s.nodes.Move(id, &req.ParentID, req.Sort)
|
||||||
}
|
}
|
||||||
jsonOK(w, map[string]string{"status": "ok"})
|
jsonOK(w, map[string]string{"status": "ok"})
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
|
|
@ -466,7 +472,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
results, err := s.search.Search(q)
|
results, err := s.search.Search(q)
|
||||||
if err != nil || len(results) == 0 {
|
if err != nil || len(results) == 0 {
|
||||||
// Fallback: search node titles directly.
|
// Fallback: search node titles directly.
|
||||||
roots, _ := s.nodes.ListRoots(false, "")
|
roots, _ := s.nodes.ListRoots(false)
|
||||||
ql := strings.ToLower(q)
|
ql := strings.ToLower(q)
|
||||||
for _, n := range roots {
|
for _, n := range roots {
|
||||||
if strings.Contains(strings.ToLower(n.Title), ql) {
|
if strings.Contains(strings.ToLower(n.Title), ql) {
|
||||||
|
|
|
||||||
|
|
@ -102,5 +102,25 @@
|
||||||
"server.emailConfirmSubject": "Confirm your Verstak Sync account",
|
"server.emailConfirmSubject": "Confirm your Verstak Sync account",
|
||||||
"server.emailConfirmBody": "Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.",
|
"server.emailConfirmBody": "Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.",
|
||||||
"server.emailResetSubject": "Verstak Sync password reset",
|
"server.emailResetSubject": "Verstak Sync password reset",
|
||||||
"server.emailResetBody": "Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour."
|
"server.emailResetBody": "Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.",
|
||||||
|
|
||||||
|
"nav.system": "System",
|
||||||
|
"nav.workspace": "Workspace",
|
||||||
|
"nav.noNodes": "No nodes",
|
||||||
|
"nav.openFolder": "Open folder",
|
||||||
|
"nav.createInside": "Create inside",
|
||||||
|
|
||||||
|
"template.folder": "Folder",
|
||||||
|
"template.project": "Project",
|
||||||
|
"template.client": "Client",
|
||||||
|
"template.document": "Document",
|
||||||
|
"template.recipe": "Recipe",
|
||||||
|
|
||||||
|
"common.archive": "Archive",
|
||||||
|
|
||||||
|
"migrate.dryRun": "Dry run",
|
||||||
|
"migrate.foldersCreated": "Folders created",
|
||||||
|
"migrate.templatesSet": "Templates set",
|
||||||
|
"migrate.errors": "Errors",
|
||||||
|
"migrate.noNodes": "No nodes to migrate"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -384,5 +384,25 @@
|
||||||
"server.emailConfirmSubject": "Подтверждение аккаунта Verstak Sync",
|
"server.emailConfirmSubject": "Подтверждение аккаунта Verstak Sync",
|
||||||
"server.emailConfirmBody": "Добро пожаловать в Verstak Sync!\n\nПодтвердите email, перейдя по ссылке:\n%s\n\nЕсли вы не регистрировались, проигнорируйте это письмо.",
|
"server.emailConfirmBody": "Добро пожаловать в Verstak Sync!\n\nПодтвердите email, перейдя по ссылке:\n%s\n\nЕсли вы не регистрировались, проигнорируйте это письмо.",
|
||||||
"server.emailResetSubject": "Сброс пароля Verstak Sync",
|
"server.emailResetSubject": "Сброс пароля Verstak Sync",
|
||||||
"server.emailResetBody": "Сброс пароля Verstak Sync:\n\n%s\n\nСсылка действительна 1 час."
|
"server.emailResetBody": "Сброс пароля Verstak Sync:\n\n%s\n\nСсылка действительна 1 час.",
|
||||||
|
|
||||||
|
"nav.system": "Системное",
|
||||||
|
"nav.workspace": "Рабочее пространство",
|
||||||
|
"nav.noNodes": "Нет узлов",
|
||||||
|
"nav.openFolder": "Открыть папку",
|
||||||
|
"nav.createInside": "Создать внутри",
|
||||||
|
|
||||||
|
"template.folder": "Папка",
|
||||||
|
"template.project": "Проект",
|
||||||
|
"template.client": "Клиент",
|
||||||
|
"template.document": "Документ",
|
||||||
|
"template.recipe": "Рецепт",
|
||||||
|
|
||||||
|
"common.archive": "Архивировать",
|
||||||
|
|
||||||
|
"migrate.dryRun": "Пробный запуск",
|
||||||
|
"migrate.foldersCreated": "Папок создано",
|
||||||
|
"migrate.templatesSet": "Шаблонов назначено",
|
||||||
|
"migrate.errors": "Ошибки",
|
||||||
|
"migrate.noNodes": "Нет узлов для миграции"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ ALLOWED_GO_CYRILLIC=(
|
||||||
"internal/gui/index.html.go"
|
"internal/gui/index.html.go"
|
||||||
"internal/i18n/catalog.go"
|
"internal/i18n/catalog.go"
|
||||||
"cmd/verstak-gui/main.go"
|
"cmd/verstak-gui/main.go"
|
||||||
|
"internal/core/templates/safename.go"
|
||||||
|
"internal/core/templates/safename_test.go"
|
||||||
)
|
)
|
||||||
|
|
||||||
echo "=== Checking for hardcoded Cyrillic in source code ==="
|
echo "=== Checking for hardcoded Cyrillic in source code ==="
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue