Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a098cf721c | ||
|
|
3672e3133b | ||
|
|
5a1c4c6d7f | ||
|
|
08c9d5dbea | ||
|
|
c74fa3ad43 | ||
|
|
69891e395c | ||
|
|
a4ae22c445 |
+5
-2
@@ -22,8 +22,11 @@ go.work
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
frontend/bindings/
|
||||
verstak-gui
|
||||
verstak-cli
|
||||
/verstak-gui
|
||||
/verstak-cli
|
||||
|
||||
# Vault data
|
||||
.verstak/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
Binary file not shown.
+312
-3
@@ -6,11 +6,14 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
@@ -28,6 +31,7 @@ type App struct {
|
||||
nodes *nodes.Repository
|
||||
files *files.Service
|
||||
notes *notes.Service
|
||||
activity *activity.Service
|
||||
actions *actions.Service
|
||||
worklog *worklog.Service
|
||||
search *search.Service
|
||||
@@ -115,6 +119,47 @@ type SearchResultDTO struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type EventDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
EventType string `json:"eventType"`
|
||||
TargetType string `json:"targetType"`
|
||||
TargetID string `json:"targetId"`
|
||||
TargetPath string `json:"targetPath"`
|
||||
Title string `json:"title"`
|
||||
DetailsJSON string `json:"detailsJson"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CaseActivityDTO struct {
|
||||
Node NodeDTO `json:"node"`
|
||||
Events []EventDTO `json:"events"`
|
||||
}
|
||||
|
||||
type SummaryDTO struct {
|
||||
ChangedCases int `json:"changedCases"`
|
||||
Notes int `json:"notes"`
|
||||
Files int `json:"files"`
|
||||
Actions int `json:"actions"`
|
||||
TimeEntries int `json:"timeEntries"`
|
||||
}
|
||||
|
||||
type TodayGroupDTO struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
NodeTitle string `json:"nodeTitle"`
|
||||
NodeKind string `json:"nodeKind"`
|
||||
Section string `json:"section"`
|
||||
LastActivityAt string `json:"lastActivityAt"`
|
||||
Events []EventDTO `json:"events"`
|
||||
}
|
||||
|
||||
type TodayDashboardDTO struct {
|
||||
Date string `json:"date"`
|
||||
Summary SummaryDTO `json:"summary"`
|
||||
Groups []TodayGroupDTO `json:"groups"`
|
||||
Events []EventDTO `json:"events"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sections
|
||||
// ============================================================
|
||||
@@ -123,6 +168,7 @@ func (a *App) ListSections() []SectionDTO {
|
||||
return []SectionDTO{
|
||||
{ID: "today", Label: "Сегодня"},
|
||||
{ID: "inbox", Label: "Неразобранное"},
|
||||
{ID: "activity", Label: "Активность"},
|
||||
{ID: "clients", Label: "Клиенты"},
|
||||
{ID: "projects", Label: "Проекты"},
|
||||
{ID: "recipes", Label: "Рецепты"},
|
||||
@@ -143,6 +189,175 @@ func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
|
||||
return toNodeDTOs(list), nil
|
||||
}
|
||||
|
||||
// ListTodayView returns a dashboard of today's activity.
|
||||
// For MVP this uses activity_events + root nodes changed today.
|
||||
// Future: full Activity/Event Log system will be the single source of truth.
|
||||
func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
|
||||
// Collect events from activity_events, grouped by parent node.
|
||||
aeByParent, err := a.activity.ListTodayEventsByParent()
|
||||
if err != nil {
|
||||
aeByParent = nil
|
||||
}
|
||||
|
||||
// Root nodes that were created/updated today.
|
||||
todayNodes, _ := a.nodes.ListTodayNodes()
|
||||
|
||||
type rawEvent struct {
|
||||
NodeID string
|
||||
EventType string
|
||||
TargetType string
|
||||
TargetID string
|
||||
TargetPath string
|
||||
Title string
|
||||
CreatedAt string
|
||||
}
|
||||
type caseInfo struct {
|
||||
Node nodes.Node
|
||||
Events []rawEvent
|
||||
}
|
||||
caseMap := make(map[string]*caseInfo)
|
||||
|
||||
ensureCase := func(caseID string) *caseInfo {
|
||||
if ci, ok := caseMap[caseID]; ok {
|
||||
return ci
|
||||
}
|
||||
ci := &caseInfo{Events: nil}
|
||||
if n, err := a.nodes.GetActive(caseID); err == nil {
|
||||
ci.Node = *n
|
||||
}
|
||||
caseMap[caseID] = ci
|
||||
return ci
|
||||
}
|
||||
|
||||
// Merge activity_events.
|
||||
for pid, events := range aeByParent {
|
||||
ci := ensureCase(pid)
|
||||
for _, e := range events {
|
||||
ci.Events = append(ci.Events, rawEvent{
|
||||
NodeID: e.NodeID,
|
||||
EventType: e.EventType,
|
||||
TargetType: e.TargetType,
|
||||
TargetID: e.TargetID,
|
||||
TargetPath: e.TargetPath,
|
||||
Title: e.Title,
|
||||
CreatedAt: e.CreatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all today's root nodes are present (even without events).
|
||||
for _, n := range todayNodes {
|
||||
_ = ensureCase(n.ID)
|
||||
if ci := caseMap[n.ID]; ci.Node.ID == "" {
|
||||
ci.Node = n
|
||||
}
|
||||
}
|
||||
|
||||
var groups []TodayGroupDTO
|
||||
var flatEvents []EventDTO
|
||||
summary := SummaryDTO{}
|
||||
|
||||
for _, ci := range caseMap {
|
||||
if ci.Node.ID == "" {
|
||||
continue
|
||||
}
|
||||
summary.ChangedCases++
|
||||
|
||||
dtoEvents := make([]EventDTO, 0, len(ci.Events))
|
||||
for _, re := range ci.Events {
|
||||
dtoEvents = append(dtoEvents, EventDTO{
|
||||
ID: ci.Node.ID + "/" + re.NodeID + "/" + re.CreatedAt,
|
||||
NodeID: re.NodeID,
|
||||
EventType: re.EventType,
|
||||
TargetType: re.TargetType,
|
||||
TargetID: re.TargetID,
|
||||
TargetPath: re.TargetPath,
|
||||
Title: re.Title,
|
||||
CreatedAt: re.CreatedAt,
|
||||
})
|
||||
switch re.EventType {
|
||||
case activity.TypeNoteCreated, activity.TypeNoteUpdated:
|
||||
summary.Notes++
|
||||
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed, activity.TypeFileCopied, activity.TypeFileMoved:
|
||||
summary.Files++
|
||||
}
|
||||
}
|
||||
|
||||
last := ci.Node.UpdatedAt.Format(time.RFC3339)
|
||||
for _, e := range dtoEvents {
|
||||
if e.CreatedAt > last {
|
||||
last = e.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
groups = append(groups, TodayGroupDTO{
|
||||
NodeID: ci.Node.ID,
|
||||
NodeTitle: ci.Node.Title,
|
||||
NodeKind: ci.Node.Type,
|
||||
Section: ci.Node.Section,
|
||||
LastActivityAt: last,
|
||||
Events: dtoEvents,
|
||||
})
|
||||
flatEvents = append(flatEvents, dtoEvents...)
|
||||
}
|
||||
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
return groups[i].LastActivityAt > groups[j].LastActivityAt
|
||||
})
|
||||
sort.Slice(flatEvents, func(i, j int) bool {
|
||||
return flatEvents[i].CreatedAt > flatEvents[j].CreatedAt
|
||||
})
|
||||
|
||||
return &TodayDashboardDTO{
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Summary: summary,
|
||||
Groups: groups,
|
||||
Events: flatEvents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toEventDTO(e activity.Event) EventDTO {
|
||||
return EventDTO{
|
||||
ID: e.ID,
|
||||
NodeID: e.NodeID,
|
||||
EventType: e.EventType,
|
||||
TargetType: e.TargetType,
|
||||
TargetID: e.TargetID,
|
||||
TargetPath: e.TargetPath,
|
||||
Title: e.Title,
|
||||
DetailsJSON: e.DetailsJSON,
|
||||
CreatedAt: e.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
|
||||
events, err := a.activity.ListRecent(limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = toEventDTO(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error) {
|
||||
events, err := a.activity.ListByNode(nodeID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = toEventDTO(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CountActivityByNode(nodeID string) (int, error) {
|
||||
return a.activity.CountByNode(nodeID)
|
||||
}
|
||||
|
||||
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
|
||||
list, err := a.nodes.ListChildren(parentID, false)
|
||||
if err != nil {
|
||||
@@ -161,10 +376,14 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
|
||||
}
|
||||
|
||||
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
|
||||
if section == "today" || section == "inbox" {
|
||||
return nil, fmt.Errorf("cannot create node with section %q", section)
|
||||
}
|
||||
n, err := a.nodes.Create(parentID, nodeType, title, section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
|
||||
dto := toNodeDTO(n)
|
||||
return &dto, nil
|
||||
}
|
||||
@@ -198,6 +417,7 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
@@ -209,7 +429,18 @@ func (a *App) ReadNote(noteID string) (string, error) {
|
||||
|
||||
// SaveNote saves note content.
|
||||
func (a *App) SaveNote(noteID, content string) error {
|
||||
return a.notes.Save(noteID, content)
|
||||
if err := a.notes.Save(noteID, content); err != nil {
|
||||
return err
|
||||
}
|
||||
// Record note_updated event.
|
||||
if n, err := a.nodes.GetActive(noteID); err == nil {
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -278,6 +509,9 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
@@ -286,10 +520,27 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteFileOrFolder(nodeID string) error {
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err == nil {
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
evType := activity.TypeFileDeleted
|
||||
targetType := activity.TargetFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
evType = activity.TypeFolderDeleted
|
||||
targetType = activity.TargetFolder
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
|
||||
}
|
||||
return a.files.DeleteNodeAndChildren(nodeID)
|
||||
}
|
||||
|
||||
@@ -298,6 +549,7 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
@@ -307,16 +559,73 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Find parent for recording
|
||||
n, err2 := a.nodes.GetActive(nodeID)
|
||||
pid := ""
|
||||
if err2 == nil && n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||
return a.nodes.UpdateTitle(nodeID, newTitle)
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldTitle := n.Title
|
||||
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
|
||||
return err
|
||||
}
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
evType := activity.TypeFileRenamed
|
||||
targetType := activity.TargetFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
evType = activity.TypeFolderRenamed
|
||||
targetType = activity.TargetFolder
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ValidateName(name string) error {
|
||||
return files.ValidateName(name)
|
||||
}
|
||||
|
||||
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||
return a.nodes.Move(nodeID, newParentID, 0)
|
||||
// Check for name conflict at destination
|
||||
destChildren, err := a.nodes.ListChildren(newParentID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range destChildren {
|
||||
if destChildren[i].Title == node.Title {
|
||||
// Conflict: auto-rename
|
||||
newName := a.files.UniqueTitleCopy(newParentID, node.Title)
|
||||
if err := a.nodes.UpdateTitle(nodeID, newName); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := a.nodes.Move(nodeID, newParentID, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
pid := ""
|
||||
if node.ParentID != nil {
|
||||
pid = *node.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,8 +16,8 @@
|
||||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
|
||||
<script type="module" crossorigin src="/assets/main-DtITCkHU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-5x3eoU2l.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
@@ -46,6 +47,7 @@ func main() {
|
||||
fileSvc := files.NewService(db, abs, nodeRepo)
|
||||
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
activitySvc := activity.NewService(db)
|
||||
worklogSvc := worklog.NewService(db)
|
||||
searchSvc := search.NewService(db)
|
||||
plugins.NewManager(abs).Discover()
|
||||
@@ -55,6 +57,7 @@ func main() {
|
||||
nodes: nodeRepo,
|
||||
files: fileSvc,
|
||||
notes: noteSvc,
|
||||
activity: activitySvc,
|
||||
actions: actionSvc,
|
||||
worklog: worklogSvc,
|
||||
search: searchSvc,
|
||||
|
||||
+384
-24
@@ -26,6 +26,12 @@
|
||||
// ===== State =====
|
||||
let sections = []
|
||||
let nodes = []
|
||||
let todayDashboard = null
|
||||
let activityFeed = []
|
||||
let activityOffset = 0
|
||||
let activityHasMore = true
|
||||
let activityLoading = false
|
||||
let caseActivity = []
|
||||
let version = ''
|
||||
let error = ''
|
||||
let selectedSection = ''
|
||||
@@ -72,6 +78,11 @@
|
||||
let confirmAction = null
|
||||
let cancelAction = null
|
||||
|
||||
let showRename = false
|
||||
let renameId = ''
|
||||
let renameValue = ''
|
||||
let renameError = ''
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'notes', label: 'Заметки' },
|
||||
@@ -94,6 +105,7 @@
|
||||
sections = [
|
||||
{ id: 'today', label: 'Сегодня' },
|
||||
{ id: 'inbox', label: 'Неразобранное' },
|
||||
{ id: 'activity', label: 'Активность' },
|
||||
{ id: 'clients', label: 'Клиенты' },
|
||||
{ id: 'projects', label: 'Проекты' },
|
||||
{ id: 'recipes', label: 'Рецепты' },
|
||||
@@ -129,11 +141,26 @@
|
||||
worklog = []
|
||||
showCreateNode = false
|
||||
error = ''
|
||||
todayDashboard = null
|
||||
activityFeed = []
|
||||
activityOffset = 0
|
||||
activityHasMore = true
|
||||
nodes = []
|
||||
try {
|
||||
if (id === 'today') {
|
||||
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||||
} else if (id === 'activity') {
|
||||
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
|
||||
activityOffset = activityFeed.length
|
||||
activityHasMore = activityFeed.length === 50
|
||||
} else {
|
||||
nodes = await wailsCall('ListNodesBySection', id) || []
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
nodes = []
|
||||
todayDashboard = { cases: [] }
|
||||
activityFeed = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +185,7 @@
|
||||
showCreateNode = false
|
||||
showCreateNote = false
|
||||
error = ''
|
||||
caseActivity = []
|
||||
await loadTabData(node.id)
|
||||
}
|
||||
|
||||
@@ -166,6 +194,7 @@
|
||||
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
||||
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
|
||||
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
|
||||
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
|
||||
}
|
||||
|
||||
async function loadTree(nodeID) {
|
||||
@@ -282,19 +311,8 @@
|
||||
}
|
||||
|
||||
function renameItem(id) {
|
||||
const name = prompt('Новое имя:')
|
||||
if (!name || !name.trim()) return
|
||||
renameSubmit(id, name.trim())
|
||||
}
|
||||
|
||||
async function renameSubmit(id, newName) {
|
||||
try {
|
||||
await wailsCall('RenameNode', id, newName)
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await loadFolder(parentId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
const item = fileItems.find(x => x.id === id)
|
||||
if (item) openRename(item.id, item.name)
|
||||
}
|
||||
|
||||
function cutItem(id) {
|
||||
@@ -343,6 +361,20 @@
|
||||
selectedIds = fileItems.map(x => x.id)
|
||||
}
|
||||
|
||||
function rangeSelect(id) {
|
||||
if (fileItems.length === 0) return
|
||||
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
|
||||
const lastIdx = fileItems.findIndex(x => x.id === lastId)
|
||||
const curIdx = fileItems.findIndex(x => x.id === id)
|
||||
if (lastIdx === -1 || curIdx === -1) return
|
||||
const start = Math.min(lastIdx, curIdx)
|
||||
const end = Math.max(lastIdx, curIdx)
|
||||
const range = fileItems.slice(start, end + 1).map(x => x.id)
|
||||
const set = new Set(selectedIds)
|
||||
range.forEach(x => set.add(x))
|
||||
selectedIds = [...set]
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds = []
|
||||
}
|
||||
@@ -421,13 +453,104 @@
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'c') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v') { e.preventDefault(); pasteItem() }
|
||||
else if (e.key === 'a') { e.preventDefault(); selectAll() }
|
||||
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
|
||||
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
|
||||
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
openSelected()
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected() }
|
||||
if (previewItem) { e.preventDefault(); closePreview(); return }
|
||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
||||
// Backspace without selection → navigate up
|
||||
if (e.key === 'Backspace' && folderStack.length > 0) {
|
||||
e.preventDefault()
|
||||
navigateBack()
|
||||
return
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
if (previewItem) { closePreview(); return }
|
||||
if (selectedIds.length > 0) { clearSelection(); return }
|
||||
} else if (e.key === 'F2') {
|
||||
e.preventDefault()
|
||||
openRenameForSelection()
|
||||
}
|
||||
}
|
||||
|
||||
function openSelected() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
if (item.type === 'folder') {
|
||||
navigateToFolder(item.id)
|
||||
} else {
|
||||
openPreview(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectedExternal() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item && item.fileId) {
|
||||
wailsCall('OpenFile', item.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Rename modal =====
|
||||
|
||||
function openRename(id, currentName) {
|
||||
renameId = id
|
||||
renameValue = currentName
|
||||
renameError = ''
|
||||
showRename = true
|
||||
}
|
||||
|
||||
function openRenameForSelection() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
openRename(item.id, item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRename() {
|
||||
const name = renameValue.trim()
|
||||
if (!name) { renameError = 'Имя не может быть пустым'; return }
|
||||
// Validate name via backend
|
||||
try {
|
||||
await wailsCall('ValidateName', name)
|
||||
} catch (e) {
|
||||
renameError = 'Недопустимое имя'
|
||||
return
|
||||
}
|
||||
showRename = false
|
||||
renameId = ''
|
||||
try {
|
||||
await wailsCall('RenameNode', renameId, name)
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await loadFolder(parentId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
showRename = false
|
||||
renameId = ''
|
||||
renameValue = ''
|
||||
renameError = ''
|
||||
}
|
||||
|
||||
function onRenameKeydown(e) {
|
||||
if (e.key === 'Enter') submitRename()
|
||||
else renameError = ''
|
||||
}
|
||||
|
||||
// ===== Confirm modal =====
|
||||
@@ -652,10 +775,59 @@
|
||||
|
||||
// ===== Helpers =====
|
||||
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
|
||||
function eventLabel(type) {
|
||||
const labels = {
|
||||
'note_created': 'Заметка создана',
|
||||
'note_updated': 'Заметка изменена',
|
||||
'file_added': 'Файл добавлен',
|
||||
'file_deleted': 'Файл удалён',
|
||||
'file_renamed': 'Файл переименован',
|
||||
'file_copied': 'Файл скопирован',
|
||||
'file_moved': 'Файл перемещён',
|
||||
'folder_added': 'Папка добавлена',
|
||||
'folder_deleted': 'Папка удалена',
|
||||
'folder_renamed': 'Папка переименована',
|
||||
'node_created': 'Дело создано',
|
||||
'node_updated': 'Дело изменено',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
function eventIcon(type) {
|
||||
if (type === 'note_created' || type === 'file_added' || type === 'folder_added' || type === 'node_created') return '+'
|
||||
if (type === 'file_deleted' || type === 'folder_deleted') return '×'
|
||||
if (type === 'file_renamed' || type === 'folder_renamed' || type === 'note_updated' || type === 'node_updated') return '~'
|
||||
if (type === 'file_copied') return '⧉'
|
||||
if (type === 'file_moved') return '→'
|
||||
return '•'
|
||||
}
|
||||
function formatTime(str) {
|
||||
if (!str) return ''
|
||||
try { return new Date(str).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' }
|
||||
}
|
||||
function formatDate(str) {
|
||||
if (!str) return ''
|
||||
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
|
||||
}
|
||||
function nodeKindLabel(kind) {
|
||||
const labels = { 'project': 'Проект', 'client': 'Клиент', 'document': 'Документ', 'recipe': 'Рецепт', 'archive': 'Архив', 'case': 'Дело' }
|
||||
return labels[kind] || kind || 'Дело'
|
||||
}
|
||||
function pluralize(n, one, few, many) {
|
||||
n = Math.abs(n) % 100
|
||||
if (n >= 5 && n <= 20) return many
|
||||
n %= 10
|
||||
if (n === 1) return one
|
||||
if (n >= 2 && n <= 4) return few
|
||||
return many
|
||||
}
|
||||
async function openNodeById(id) {
|
||||
try {
|
||||
const node = await wailsCall('GetNodeDetail', id)
|
||||
if (node) selectNode(node)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
@@ -675,7 +847,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if selectedSection}
|
||||
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
|
||||
{#each nodes as node}
|
||||
@@ -832,7 +1004,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#if folderStack.length > 0}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => {
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => {
|
||||
const i = e.detail
|
||||
if (i === 0) {
|
||||
folderStack = []
|
||||
@@ -847,7 +1019,7 @@
|
||||
Back
|
||||
</button>
|
||||
{:else}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/>
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/>
|
||||
{/if}
|
||||
|
||||
{#if fileItems.length === 0}
|
||||
@@ -877,6 +1049,7 @@
|
||||
on:navigate={(e) => navigateToFolder(e.detail)}
|
||||
on:preview={(e) => openPreview(e.detail)}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
|
||||
on:delete={(e) => deleteFile(e.detail)}
|
||||
on:rename={(e) => renameItem(e.detail.id)}
|
||||
on:duplicate={(e) => duplicateItem(e.detail)}
|
||||
@@ -884,6 +1057,7 @@
|
||||
on:copy={(e) => copyItem(e.detail)}
|
||||
on:selectOne={(e) => selectOne(e.detail)}
|
||||
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
||||
on:rangeSelect={(e) => rangeSelect(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -939,7 +1113,111 @@
|
||||
</div>
|
||||
|
||||
{:else if activeTab === 'activity'}
|
||||
<div class="empty-state"><p>Активность появится позже</p></div>
|
||||
<div class="activity-tab">
|
||||
{#if caseActivity.length === 0}
|
||||
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||||
{:else}
|
||||
<div class="activity-events">
|
||||
{#each caseActivity as ev}
|
||||
<div class="activity-event">
|
||||
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
|
||||
<span class="activity-event-title">{ev.title}</span>
|
||||
<span class="activity-event-type">{eventLabel(ev.eventType)}</span>
|
||||
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||||
<span class="activity-event-time">{formatTime(ev.createdAt)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedSection === 'today' && todayDashboard}
|
||||
<div class="today-dashboard">
|
||||
<div class="today-header">
|
||||
<h2>Сегодня</h2>
|
||||
<span class="today-date">{todayDashboard.date}</span>
|
||||
</div>
|
||||
{#if todayDashboard.summary}
|
||||
<div class="today-summary">
|
||||
{#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, 'дело', 'дела', 'дел')}</span>{/if}
|
||||
{#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, 'заметка', 'заметки', 'заметок')}</span>{/if}
|
||||
{#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, 'файл', 'файла', 'файлов')}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if todayDashboard.groups && todayDashboard.groups.length > 0}
|
||||
{#each todayDashboard.groups as group}
|
||||
<div class="today-case">
|
||||
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
||||
<span class="today-case-title">{group.nodeTitle}</span>
|
||||
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
|
||||
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, 'событие', 'события', 'событий')}</span>{/if}
|
||||
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
|
||||
</div>
|
||||
{#if group.events && group.events.length > 0}
|
||||
<div class="today-events">
|
||||
{#each group.events as ev}
|
||||
<div class="today-event" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
||||
<span class="today-event-icon">{eventIcon(ev.eventType)}</span>
|
||||
<span class="today-event-title">{ev.title}</span>
|
||||
<span class="today-event-type">{eventLabel(ev.eventType)}</span>
|
||||
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||||
<span class="today-event-time">{formatTime(ev.createdAt)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="today-events-empty">Изменён сегодня</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if todayDashboard.events && todayDashboard.events.length > 0}
|
||||
<div class="today-timeline">
|
||||
<h3>Лента за сегодня</h3>
|
||||
{#each todayDashboard.events as ev}
|
||||
<div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||||
<span class="timeline-dot"></span>
|
||||
<span class="timeline-title">{ev.title}</span>
|
||||
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
|
||||
<span class="timeline-time">{formatTime(ev.createdAt)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="today-empty">
|
||||
<p>Сегодня пока тихо</p>
|
||||
<p class="hint">Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedSection === 'activity'}
|
||||
<div class="activity-feed">
|
||||
<div class="activity-feed-header">
|
||||
<h2>Активность</h2>
|
||||
</div>
|
||||
{#if activityFeed.length === 0}
|
||||
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||||
{:else}
|
||||
<div class="activity-feed-events">
|
||||
{#each activityFeed as ev}
|
||||
<div class="activity-feed-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||||
<span class="activity-feed-icon">{eventIcon(ev.eventType)}</span>
|
||||
<div class="activity-feed-body">
|
||||
<span class="activity-feed-title">{ev.title}</span>
|
||||
<div class="activity-feed-meta">
|
||||
<span class="activity-feed-type">{eventLabel(ev.eventType)}</span>
|
||||
{#if ev.targetType}<span class="activity-feed-target">{ev.targetType}</span>{/if}
|
||||
<span class="activity-feed-time">{formatDate(ev.createdAt)} {formatTime(ev.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -954,7 +1232,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !noteEditor && !selectedNode}
|
||||
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
|
||||
{/if}
|
||||
|
||||
@@ -970,7 +1248,7 @@
|
||||
<div class="form-group">
|
||||
<label>Раздел</label>
|
||||
<select bind:value={newNodeSection}>
|
||||
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox') as s}
|
||||
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox' && s.id !== 'activity') as s}
|
||||
<option value={s.id}>{s.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -1007,6 +1285,26 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRename}
|
||||
<div class="modal-overlay" on:click|self={cancelRename}>
|
||||
<div class="modal">
|
||||
<h3>Переименовать</h3>
|
||||
<div class="form-group">
|
||||
<label>Новое имя</label>
|
||||
<input type="text" bind:value={renameValue}
|
||||
on:keydown={onRenameKeydown} />
|
||||
</div>
|
||||
{#if renameError}
|
||||
<div class="rename-error">{renameError}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button>
|
||||
<button class="btn" on:click={cancelRename}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showConfirm}
|
||||
<ConfirmModal
|
||||
title={confirmTitle}
|
||||
@@ -1159,4 +1457,66 @@
|
||||
.import-summary { margin-bottom: 16px; }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
|
||||
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
|
||||
|
||||
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
|
||||
|
||||
/* Today Dashboard */
|
||||
.today-dashboard { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
||||
.today-header h2 { font-size: 24px; }
|
||||
.today-date { font-size: 13px; color: #666; }
|
||||
.today-summary { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.summary-chip { font-size: 12px; color: #b0b0c0; background: #1a1a28; border: 1px solid #2a2a3c; padding: 4px 12px; border-radius: 16px; }
|
||||
.today-case { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
|
||||
.today-case-header { padding: 12px 16px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #2a2a3c; cursor: pointer; }
|
||||
.today-case-header:hover { background: #1e1e30; }
|
||||
.today-case-title { font-weight: 500; }
|
||||
.today-case-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
|
||||
.today-case-count { font-size: 11px; color: #6366f1; margin-left: 4px; }
|
||||
.today-case-time { font-size: 11px; color: #555; margin-left: auto; }
|
||||
.today-events { padding: 8px 16px; }
|
||||
.today-event { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; color: #b0b0c0; cursor: pointer; }
|
||||
.today-event:hover { color: #e4e4ef; }
|
||||
.today-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; }
|
||||
.today-event-title { flex: 1; }
|
||||
.today-event-type { font-size: 11px; color: #666; }
|
||||
.today-event-time { font-size: 11px; color: #555; margin-left: auto; }
|
||||
.today-events-empty { padding: 8px 16px; font-size: 13px; color: #666; font-style: italic; }
|
||||
.today-empty { padding: 48px 24px; text-align: center; }
|
||||
.today-empty p { color: #666; font-size: 14px; margin: 0; }
|
||||
.today-empty .hint { font-size: 12px; color: #555; margin-top: 8px; }
|
||||
.today-timeline { margin-top: 24px; }
|
||||
.today-timeline h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 12px; }
|
||||
.timeline-event { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; color: #b0b0c0; border-left: 2px solid #2a2a3c; padding-left: 16px; margin-left: 4px; cursor: pointer; }
|
||||
.timeline-event:hover { color: #e4e4ef; }
|
||||
.timeline-dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; margin-left: -19px; flex-shrink: 0; }
|
||||
.timeline-title { flex: 1; }
|
||||
.timeline-type { font-size: 11px; color: #666; }
|
||||
.timeline-time { font-size: 11px; color: #555; }
|
||||
|
||||
/* Activity tab (per-case) */
|
||||
.activity-tab { padding: 24px; }
|
||||
.activity-events { display: flex; flex-direction: column; gap: 2px; }
|
||||
.activity-event { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 13px; color: #b0b0c0; cursor: pointer; }
|
||||
.activity-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||||
.activity-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; }
|
||||
.activity-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-event-type { font-size: 11px; color: #666; flex-shrink: 0; }
|
||||
.activity-event-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; }
|
||||
.activity-event-time { font-size: 11px; color: #555; margin-left: 8px; flex-shrink: 0; }
|
||||
|
||||
/* Activity feed (global section) */
|
||||
.activity-feed { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.activity-feed-header { margin-bottom: 20px; }
|
||||
.activity-feed-header h2 { font-size: 24px; }
|
||||
.activity-feed-events { display: flex; flex-direction: column; gap: 2px; }
|
||||
.activity-feed-event { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
|
||||
.activity-feed-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||||
.activity-feed-icon { width: 20px; text-align: center; color: #6366f1; font-size: 14px; flex-shrink: 0; margin-top: 1px; }
|
||||
.activity-feed-body { flex: 1; min-width: 0; }
|
||||
.activity-feed-title { font-size: 14px; color: #e4e4ef; }
|
||||
.activity-feed-meta { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
||||
.activity-feed-type { font-size: 11px; color: #666; }
|
||||
.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
|
||||
.activity-feed-time { font-size: 11px; color: #555; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import FileIcon from './lib/FileIcon.svelte'
|
||||
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
|
||||
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
|
||||
|
||||
export let item
|
||||
export let selected = false
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
const kind = getFileKind(item)
|
||||
const isFolder = item.type === 'folder'
|
||||
const fileType = formatFileType(item)
|
||||
let menuOpen = false
|
||||
let clickTimer = null
|
||||
|
||||
@@ -25,7 +26,7 @@
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer)
|
||||
clickTimer = null
|
||||
// Double click
|
||||
// Double click → открыть
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
@@ -34,37 +35,23 @@
|
||||
} else {
|
||||
clickTimer = setTimeout(() => {
|
||||
clickTimer = null
|
||||
// Single click: select
|
||||
if (selected) {
|
||||
// Already selected: navigate/preview
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
} else {
|
||||
// Single click → выделить
|
||||
dispatch('selectOne', item.id)
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenExternal() {
|
||||
dispatch('openExternal', item.fileId)
|
||||
}
|
||||
|
||||
function handleShowInFolder() {
|
||||
dispatch('showInFolder', item.id)
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
menuOpen = false
|
||||
dispatch('delete', { id: item.id, type: item.type })
|
||||
}
|
||||
|
||||
@@ -88,6 +75,15 @@
|
||||
dispatch('copy', item.id)
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
menuOpen = false
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen = !menuOpen
|
||||
}
|
||||
@@ -107,48 +103,48 @@
|
||||
function handleDrop(e) {
|
||||
if (onDrop && isFolder) onDrop(e, item.id)
|
||||
}
|
||||
|
||||
function oncontextmenu(e) {
|
||||
e.preventDefault()
|
||||
menuOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeMenu}/>
|
||||
|
||||
<div class="file-row"
|
||||
class:file-row--selected={selected}
|
||||
class:file-row--dragover={false}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
on:click={handleClick}
|
||||
on:keydown={handleKeydown}
|
||||
on:contextmenu={oncontextmenu}
|
||||
on:dragstart={handleDragStart}
|
||||
on:dragover={handleDragOver}
|
||||
on:drop={handleDrop}
|
||||
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
|
||||
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
|
||||
<div class="file-row-icon">
|
||||
<FileIcon {kind} size={22}/>
|
||||
</div>
|
||||
<div class="file-row-body">
|
||||
<div class="file-row-name" title={item.name}>{item.name}</div>
|
||||
<div class="file-row-meta">
|
||||
{#if isFolder}
|
||||
<span>Folder</span>
|
||||
{:else}
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
{#if item.mime}
|
||||
<span>{fileType}</span>
|
||||
{#if !isFolder}
|
||||
<span class="meta-sep">·</span>
|
||||
<span>{formatMimeType(item.mime)}</span>
|
||||
{/if}
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-row-actions">
|
||||
{#if !isFolder}
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Preview" aria-label="Preview">
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
@@ -156,21 +152,21 @@
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="More actions" aria-label="More actions" aria-expanded={menuOpen}>
|
||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<circle cx="12" cy="19" r="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Delete" aria-label="Delete">
|
||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
@@ -183,10 +179,42 @@
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
||||
<div class="menu" on:click|stopPropagation role="menu">
|
||||
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button>
|
||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button>
|
||||
<button class="menu-item" on:click={handleCut} role="menuitem">Cut</button>
|
||||
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button>
|
||||
<button class="menu-item" on:click={handleOpen} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
Открыть
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Открыть во внешней программе
|
||||
</button>
|
||||
{#if isFolder}
|
||||
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
Показать в проводнике
|
||||
</button>
|
||||
{/if}
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item" on:click={handleRename} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Переименовать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Дублировать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleCut} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
|
||||
Вырезать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleCopy} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Копировать
|
||||
</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -201,6 +229,7 @@
|
||||
transition: background 0.12s;
|
||||
min-height: 52px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
@@ -314,17 +343,19 @@
|
||||
margin-top: 4px;
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
min-width: 140px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
min-width: 220px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
@@ -337,10 +368,25 @@
|
||||
|
||||
.menu-item:hover {
|
||||
background: #2a2a3c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.menu-item-danger {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.menu-item-danger:hover {
|
||||
background: #3a2222;
|
||||
}
|
||||
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.menu-sep {
|
||||
height: 1px;
|
||||
background: #2a2a3c;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as App from '../wailsjs/go/main/App.js'
|
||||
|
||||
// Re-export all methods
|
||||
export const listSections = () => App.ListSections()
|
||||
export const listTodayView = () => App.ListTodayView()
|
||||
export const listNodesBySection = (section) => App.ListNodesBySection(section)
|
||||
export const listChildren = (parentID) => App.ListChildren(parentID)
|
||||
export const getNodeDetail = (id) => App.GetNodeDetail(id)
|
||||
@@ -31,6 +32,7 @@ export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
|
||||
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
|
||||
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
|
||||
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
|
||||
export const validateName = (name) => App.ValidateName(name)
|
||||
|
||||
export const listActions = (nodeID) => App.ListActions(nodeID)
|
||||
export const runAction = (id) => App.RunAction(id)
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
||||
<div class="preview-actions">
|
||||
<button class="action-btn" on:click={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
||||
<button class="action-btn" on:click={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
@@ -60,11 +60,11 @@
|
||||
</header>
|
||||
<div class="preview-body">
|
||||
{#if loading}
|
||||
<div class="preview-status"><p>Loading preview...</p></div>
|
||||
<div class="preview-status"><p>Загрузка...</p></div>
|
||||
{:else if error}
|
||||
<div class="preview-status">
|
||||
<p>{error}</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{:else if showImage && content}
|
||||
<div class="preview-image-container">
|
||||
@@ -79,14 +79,14 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="preview-status">
|
||||
<p>PDF preview is not available in this environment.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
<p>Предпросмотр PDF недоступен.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="preview-status">
|
||||
<p>Preview is not available for this file type.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
<p>Предпросмотр недоступен для этого типа файлов.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -8,43 +8,53 @@ export function formatFileSize(bytes) {
|
||||
}
|
||||
|
||||
const mimeLabels = {
|
||||
'image/jpeg': 'JPEG image',
|
||||
'image/png': 'PNG image',
|
||||
'image/gif': 'GIF image',
|
||||
'image/webp': 'WebP image',
|
||||
'image/svg+xml': 'SVG image',
|
||||
'image/bmp': 'BMP image',
|
||||
'image/tiff': 'TIFF image',
|
||||
'image/avif': 'AVIF image',
|
||||
'application/pdf': 'PDF document',
|
||||
'application/msword': 'Word document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
|
||||
'application/vnd.ms-excel': 'Excel spreadsheet',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
|
||||
'application/vnd.ms-powerpoint': 'PowerPoint presentation',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
|
||||
'application/zip': 'ZIP archive',
|
||||
'application/gzip': 'GZIP archive',
|
||||
'application/x-tar': 'TAR archive',
|
||||
'application/x-7z-compressed': '7z archive',
|
||||
'application/x-rar-compressed': 'RAR archive',
|
||||
'text/plain': 'Text file',
|
||||
'text/html': 'HTML file',
|
||||
'text/css': 'CSS file',
|
||||
'text/javascript': 'JavaScript file',
|
||||
'application/json': 'JSON file',
|
||||
'application/xml': 'XML file',
|
||||
'application/x-yaml': 'YAML file',
|
||||
'application/octet-stream': 'Binary file',
|
||||
'application/x-msdos-program': 'Executable',
|
||||
'inode/directory': 'Folder',
|
||||
'image/jpeg': 'Изображение JPEG',
|
||||
'image/png': 'Изображение PNG',
|
||||
'image/gif': 'Изображение GIF',
|
||||
'image/webp': 'Изображение WebP',
|
||||
'image/svg+xml': 'Изображение SVG',
|
||||
'image/bmp': 'Изображение BMP',
|
||||
'image/tiff': 'Изображение TIFF',
|
||||
'image/avif': 'Изображение AVIF',
|
||||
'application/pdf': 'PDF документ',
|
||||
'application/msword': 'Документ Word',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
|
||||
'application/vnd.ms-excel': 'Таблица Excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
|
||||
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
|
||||
'application/zip': 'ZIP архив',
|
||||
'application/gzip': 'GZIP архив',
|
||||
'application/x-tar': 'TAR архив',
|
||||
'application/x-7z-compressed': '7z архив',
|
||||
'application/x-rar-compressed': 'RAR архив',
|
||||
'text/plain': 'Текстовый файл',
|
||||
'text/html': 'HTML файл',
|
||||
'text/css': 'CSS файл',
|
||||
'text/javascript': 'JavaScript файл',
|
||||
'application/json': 'JSON файл',
|
||||
'application/xml': 'XML файл',
|
||||
'application/x-yaml': 'YAML файл',
|
||||
'application/octet-stream': 'Бинарный файл',
|
||||
'application/x-msdos-program': 'Исполняемый файл',
|
||||
'inode/directory': 'Папка',
|
||||
}
|
||||
|
||||
export function formatMimeType(mime) {
|
||||
if (!mime) return 'Unknown'
|
||||
if (!mime) return 'Неизвестно'
|
||||
return mimeLabels[mime] || mime
|
||||
}
|
||||
|
||||
export function formatFileType(item) {
|
||||
if (item.type === 'folder') return 'Папка'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
if (mimeLabels[mime]) return mimeLabels[mime]
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
if (ext) return ext.toUpperCase()
|
||||
return 'Файл'
|
||||
}
|
||||
|
||||
export function getFileKind(item) {
|
||||
if (item.type === 'folder') return 'folder'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
|
||||
@@ -6,6 +6,10 @@ export function ListSections() {
|
||||
return window['go']['main']['App']['ListSections']();
|
||||
}
|
||||
|
||||
export function ListTodayView() {
|
||||
return window['go']['main']['App']['ListTodayView']();
|
||||
}
|
||||
|
||||
export function ListNodesBySection(arg1) {
|
||||
return window['go']['main']['App']['ListNodesBySection'](arg1);
|
||||
}
|
||||
@@ -105,3 +109,19 @@ export function OpenFolder(arg1) {
|
||||
export function VerstakVersion() {
|
||||
return window['go']['main']['App']['VerstakVersion']();
|
||||
}
|
||||
|
||||
export function ListActivityFeed(arg1, arg2) {
|
||||
return window['go']['main']['App']['ListActivityFeed'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ListActivityByNode(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ListActivityByNode'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function CountActivityByNode(arg1) {
|
||||
return window['go']['main']['App']['CountActivityByNode'](arg1);
|
||||
}
|
||||
|
||||
export function CreateEmptyFile(arg1, arg2) {
|
||||
return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
// Event types.
|
||||
const (
|
||||
TypeNoteCreated = "note_created"
|
||||
TypeNoteUpdated = "note_updated"
|
||||
TypeFileAdded = "file_added"
|
||||
TypeFileDeleted = "file_deleted"
|
||||
TypeFileRenamed = "file_renamed"
|
||||
TypeFileCopied = "file_copied"
|
||||
TypeFileMoved = "file_moved"
|
||||
TypeFolderAdded = "folder_added"
|
||||
TypeFolderDeleted = "folder_deleted"
|
||||
TypeFolderRenamed = "folder_renamed"
|
||||
TypeNodeCreated = "node_created"
|
||||
TypeNodeUpdated = "node_updated"
|
||||
TypeActionCreated = "action_created"
|
||||
TypeActionDone = "action_done"
|
||||
TypeWorklogAdded = "worklog_added"
|
||||
)
|
||||
|
||||
// Target types.
|
||||
const (
|
||||
TargetNote = "note"
|
||||
TargetFile = "file"
|
||||
TargetFolder = "folder"
|
||||
TargetAction = "action"
|
||||
TargetNode = "node"
|
||||
TargetWorklog = "worklog"
|
||||
)
|
||||
|
||||
// Event represents an activity event.
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
EventType string `json:"event_type"`
|
||||
TargetType string `json:"target_type,omitempty"`
|
||||
TargetID string `json:"target_id,omitempty"`
|
||||
TargetPath string `json:"target_path,omitempty"`
|
||||
Title string `json:"title"`
|
||||
DetailsJSON string `json:"details_json,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Service records and queries activity events.
|
||||
type Service struct {
|
||||
db *storage.DB
|
||||
}
|
||||
|
||||
func NewService(db *storage.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
// Record inserts a new activity event.
|
||||
func (s *Service) Record(nodeID, targetType, targetID, targetPath, eventType, title, detailsJSON string) error {
|
||||
id := util.UUID7()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
id, nodeID, eventType, targetType, targetID, targetPath, title, detailsJSON, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// TodayBoundaries returns the current local day in UTC as RFC3339 strings.
|
||||
func TodayBoundaries() (string, string) {
|
||||
now := time.Now()
|
||||
y, m, d := now.Date()
|
||||
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
end := start.Add(24 * time.Hour)
|
||||
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// scanEvent scans a single event row.
|
||||
func scanEvent(rows *sql.Rows) (Event, error) {
|
||||
var e Event
|
||||
err := rows.Scan(&e.ID, &e.NodeID, &e.EventType,
|
||||
&e.TargetType, &e.TargetID, &e.TargetPath,
|
||||
&e.Title, &e.DetailsJSON, &e.CreatedAt)
|
||||
return e, err
|
||||
}
|
||||
|
||||
// ListTodayEvents returns all activity events from today.
|
||||
func (s *Service) ListTodayEvents() ([]Event, error) {
|
||||
start, end := TodayBoundaries()
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
|
||||
title,COALESCE(metadata,'{}'),created_at
|
||||
FROM activity_events
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
ORDER BY created_at DESC`, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []Event
|
||||
for rows.Next() {
|
||||
e, err := scanEvent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// ListTodayEventsByParent returns today's events grouped by their NodeID.
|
||||
func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
|
||||
events, err := s.ListTodayEvents()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grouped := make(map[string][]Event, 8)
|
||||
for _, e := range events {
|
||||
pid := e.NodeID
|
||||
grouped[pid] = append(grouped[pid], e)
|
||||
}
|
||||
return grouped, nil
|
||||
}
|
||||
|
||||
// ListRecent returns a paginated global activity feed.
|
||||
func (s *Service) ListRecent(limit, offset int) ([]Event, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
|
||||
title,COALESCE(metadata,'{}'),created_at
|
||||
FROM activity_events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []Event
|
||||
for rows.Next() {
|
||||
e, err := scanEvent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// ListByNode returns paginated events for a specific node.
|
||||
func (s *Service) ListByNode(nodeID string, limit, offset int) ([]Event, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
|
||||
title,COALESCE(metadata,'{}'),created_at
|
||||
FROM activity_events
|
||||
WHERE node_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?`, nodeID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []Event
|
||||
for rows.Next() {
|
||||
e, err := scanEvent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// CountByNode returns the total number of events for a node.
|
||||
func (s *Service) CountByNode(nodeID string) (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM activity_events WHERE node_id = ?`, nodeID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
+120
-18
@@ -60,6 +60,59 @@ func (s *Service) DB() *storage.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// --- security helpers ---
|
||||
|
||||
// ValidateName is an exported wrapper for validateName.
|
||||
func ValidateName(name string) error {
|
||||
return validateName(name)
|
||||
}
|
||||
|
||||
// validateName rejects filenames with path separators, relative components,
|
||||
// and other dangerous patterns.
|
||||
func validateName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
||||
return fmt.Errorf("name must not contain path separators")
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
return fmt.Errorf("name must not contain '..'")
|
||||
}
|
||||
if strings.Contains(name, "\x00") {
|
||||
return fmt.Errorf("name must not contain null bytes")
|
||||
}
|
||||
if len(name) > 255 {
|
||||
return fmt.Errorf("name too long (max 255)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vaultPath resolves a relative vault path and checks it stays within jail.
|
||||
func (s *Service) vaultPath(rel string) (string, error) {
|
||||
abs := filepath.Join(s.vaultRoot, rel)
|
||||
cleaned := filepath.Clean(abs)
|
||||
if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) {
|
||||
return "", fmt.Errorf("path escapes vault root")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// absPathSafe resolves an absolute path and checks jail if it's under vault.
|
||||
// For "external" mode records the path stored may be an absolute external path.
|
||||
// This function only checks path safety — it does not enforce that external
|
||||
// files must be inside the vault.
|
||||
func (s *Service) absPathSafe(rec *Record) (string, error) {
|
||||
if rec.StorageMode == "vault" {
|
||||
return s.vaultPath(rec.Path)
|
||||
}
|
||||
abs, err := filepath.Abs(rec.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("abs: %w", err)
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
|
||||
// --- public operations ---
|
||||
|
||||
// AddExternal registers an external file (absolute path) without copying.
|
||||
@@ -84,6 +137,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
|
||||
}
|
||||
|
||||
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
|
||||
if _, err := s.vaultPath(filepath.Join("spaces", nodeSlug)); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
@@ -105,6 +161,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
if _, err := s.vaultPath(relPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
|
||||
}
|
||||
|
||||
@@ -149,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
|
||||
return err
|
||||
}
|
||||
if rec.StorageMode == "vault" {
|
||||
src := filepath.Join(s.vaultRoot, rec.Path)
|
||||
src, err := s.vaultPath(rec.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
|
||||
// verify trash is inside vault
|
||||
if _, err := s.vaultPath(filepath.Join(".verstak", "trash", rec.ID+"_"+rec.Filename)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(src, dest); err != nil {
|
||||
return fmt.Errorf("move to trash: %w", err)
|
||||
}
|
||||
@@ -169,11 +235,9 @@ func (s *Service) Open(id string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return openWithSystem(abs)
|
||||
}
|
||||
@@ -190,11 +254,9 @@ func (s *Service) ReadText(id string) (string, error) {
|
||||
if rec.Size > maxPreviewSize {
|
||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
@@ -212,11 +274,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
|
||||
if rec.Size > maxPreviewSize {
|
||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
@@ -231,6 +291,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
|
||||
|
||||
// CreateEmptyFile creates a file node and an empty vault file.
|
||||
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
|
||||
if err := validateName(filename); err != nil {
|
||||
return nil, fmt.Errorf("invalid filename: %w", err)
|
||||
}
|
||||
filename = s.uniqueTitle(parentID, filename)
|
||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
||||
if err != nil {
|
||||
@@ -247,6 +310,10 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error
|
||||
}
|
||||
f.Close()
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
// Verify dest is inside vault
|
||||
if _, err := s.vaultPath(relPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
|
||||
return node, err
|
||||
}
|
||||
@@ -261,7 +328,7 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||
if original.ParentID != nil {
|
||||
parentID = *original.ParentID
|
||||
}
|
||||
newName := s.uniqueTitle(parentID, original.Title)
|
||||
newName := s.copyTitle(parentID, original.Title)
|
||||
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -271,7 +338,10 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||
if len(records) > 0 {
|
||||
src := &records[0]
|
||||
if src.StorageMode == "vault" {
|
||||
srcPath := filepath.Join(s.vaultRoot, src.Path)
|
||||
srcPath, err := s.vaultPath(src.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
||||
os.MkdirAll(dir, 0o750)
|
||||
dst := filepath.Join(dir, newName)
|
||||
@@ -280,6 +350,9 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||
return nil, fmt.Errorf("copy file: %w", err)
|
||||
}
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dst)
|
||||
if _, err := s.vaultPath(relPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -452,6 +525,35 @@ func (s *Service) uniqueTitle(parentID, desired string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// copyTitle generates a unique "Name (copy).ext" style name for duplicates.
|
||||
// For files with extensions: "photo.jpg" → "photo (copy).jpg", "photo (copy 2).jpg"
|
||||
// For folders: "Docs" → "Docs (copy)", "Docs (copy 2)"
|
||||
func (s *Service) copyTitle(parentID, desired string) string {
|
||||
children, _ := s.nodes.ListChildren(parentID, false)
|
||||
used := make(map[string]bool, len(children))
|
||||
for i := range children {
|
||||
used[children[i].Title] = true
|
||||
}
|
||||
|
||||
ext := filepath.Ext(desired)
|
||||
base := strings.TrimSuffix(desired, ext)
|
||||
copyName := base + " (copy)" + ext
|
||||
if !used[copyName] {
|
||||
return copyName
|
||||
}
|
||||
for n := 2; ; n++ {
|
||||
candidate := fmt.Sprintf("%s (copy %d)%s", base, n, ext)
|
||||
if !used[candidate] {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UniqueTitleCopy returns a copy-style unique name for use in conflict resolution.
|
||||
func (s *Service) UniqueTitleCopy(parentID, desired string) string {
|
||||
return s.copyTitle(parentID, desired)
|
||||
}
|
||||
|
||||
// --- implementation details ---
|
||||
|
||||
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
||||
|
||||
@@ -50,16 +50,10 @@ func now() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
// Valid sections for root-level nodes.
|
||||
var validSections = map[string]struct{}{
|
||||
"clients": {}, "projects": {}, "recipes": {}, "documents": {}, "archive": {},
|
||||
}
|
||||
|
||||
// Create inserts a root or child node.
|
||||
// parentID may be empty for root-level nodes.
|
||||
// For root nodes, section determines sidebar placement (may be empty → inbox).
|
||||
// For root nodes, section determines sidebar placement (may be empty = inbox).
|
||||
// section must be a valid section (clients, projects, etc.) or empty for inbox.
|
||||
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
|
||||
if !IsValidType(typ) {
|
||||
return nil, fmt.Errorf("invalid node type: %s", typ)
|
||||
@@ -67,6 +61,9 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
|
||||
if title == "" {
|
||||
return nil, errors.New("title is required")
|
||||
}
|
||||
if section != "" && !IsValidSection(section) {
|
||||
return nil, fmt.Errorf("invalid section: %s", section)
|
||||
}
|
||||
|
||||
n := &Node{
|
||||
ID: util.UUID7(),
|
||||
@@ -87,9 +84,22 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Bump parent's updated_at so it appears in today view.
|
||||
if parentID != "" {
|
||||
_ = r.touch(parentID)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// touch updates a node's updated_at without changing other fields.
|
||||
func (r *Repository) touch(id string) error {
|
||||
t := now()
|
||||
_, err := r.db.Exec(
|
||||
`UPDATE nodes SET updated_at=?, revision=revision+1
|
||||
WHERE id=? AND deleted_at IS NULL`, t, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) insertNode(n *Node) error {
|
||||
var parent interface{}
|
||||
if n.ParentID != nil {
|
||||
@@ -182,6 +192,41 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
|
||||
return scanNodes(rows)
|
||||
}
|
||||
|
||||
// todayBoundaries returns RFC3339 start and end strings for the current day
|
||||
// in UTC, so string comparison against UTC-stored DB timestamps is correct.
|
||||
func todayBoundaries() (string, string) {
|
||||
now := time.Now()
|
||||
y, m, d := now.Date()
|
||||
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
end := start.Add(24 * time.Hour)
|
||||
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// ListTodayNodes returns active root-level nodes created or updated today.
|
||||
// This is a dynamic view, not a section — it shows the day's activity.
|
||||
// Child nodes (notes, files, folders) are not listed directly; instead,
|
||||
// their parent is bumped via touch() on creation.
|
||||
func (r *Repository) ListTodayNodes() ([]Node, error) {
|
||||
start, end := todayBoundaries()
|
||||
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
||||
created_at,updated_at,deleted_at,revision,device_id
|
||||
FROM nodes
|
||||
WHERE deleted_at IS NULL
|
||||
AND parent_id IS NULL
|
||||
AND (
|
||||
(created_at >= ? AND created_at < ?)
|
||||
OR
|
||||
(updated_at >= ? AND updated_at < ?)
|
||||
)
|
||||
ORDER BY updated_at DESC, created_at DESC`
|
||||
rows, err := r.db.Query(q, start, end, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanNodes(rows)
|
||||
}
|
||||
|
||||
// UpdateTitle changes title (slug is recomputed).
|
||||
func (r *Repository) UpdateTitle(id, title string) error {
|
||||
if title == "" {
|
||||
|
||||
@@ -35,12 +35,41 @@ var TypeSet = map[string]struct{}{
|
||||
TypeLink: {},
|
||||
}
|
||||
|
||||
// Valid sections for root-level nodes.
|
||||
// today and inbox are service sections, not stored in nodes.section.
|
||||
var validSections = map[string]struct{}{
|
||||
"clients": {},
|
||||
"projects": {},
|
||||
"recipes": {},
|
||||
"documents": {},
|
||||
"archive": {},
|
||||
}
|
||||
|
||||
// serviceSections are sidebar entries that are not stored as node sections.
|
||||
var serviceSections = map[string]struct{}{
|
||||
"today": {},
|
||||
"inbox": {},
|
||||
"activity": {},
|
||||
}
|
||||
|
||||
// IsValidType checks whether a type string is recognized.
|
||||
func IsValidType(t string) bool {
|
||||
_, ok := TypeSet[t]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsValidSection returns true for sections that can be stored on a node.
|
||||
func IsValidSection(s string) bool {
|
||||
_, ok := validSections[s]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsServiceSection returns true for sidebar-only sections (today, inbox).
|
||||
func IsServiceSection(s string) bool {
|
||||
_, ok := serviceSections[s]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Slugify converts a title into a filesystem-safe slug.
|
||||
// Examples:
|
||||
//
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package storage
|
||||
|
||||
// migration008 — activity_events table for the Сегодня (Today) dashboard.
|
||||
const migration008 = `
|
||||
CREATE TABLE IF NOT EXISTS activity_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ae_created ON activity_events(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ae_parent ON activity_events(parent_id);
|
||||
`
|
||||
@@ -0,0 +1,8 @@
|
||||
package storage
|
||||
|
||||
// migration009 — add target_type, target_id, target_path to activity_events.
|
||||
const migration009 = `
|
||||
ALTER TABLE activity_events ADD COLUMN target_type TEXT;
|
||||
ALTER TABLE activity_events ADD COLUMN target_id TEXT;
|
||||
ALTER TABLE activity_events ADD COLUMN target_path TEXT;
|
||||
`
|
||||
@@ -64,7 +64,8 @@ var migrationFiles = map[int]string{
|
||||
5: migration005,
|
||||
6: migration006,
|
||||
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
||||
// 8: migration008, etc.
|
||||
8: migration008,
|
||||
9: migration009,
|
||||
}
|
||||
|
||||
func (db *DB) runInitialSchema() error {
|
||||
|
||||
Reference in New Issue
Block a user