Author SHA1 Message Date
mirivlad 69891e395c Today view and Inbox section: dynamic query, not stored sections
- ListTodayView() on backend: queries nodes created or updated today
  using local timezone day boundaries
- todayBoundaries() helper returns start/end of current day in RFC3339
- Section validation: Create() rejects today and inbox as node sections
- validSections moved from repository.go to types.go with IsValidSection
  and IsServiceSection helpers
- Frontend selectSection('today') calls ListTodayView instead of
  ListNodesBySection('today')
- FAB (create node) hidden in today and inbox sections
- CreateNode in app.go guarded against today/inbox sections
- types.go: today/inbox defined as service sections (sidebar only)
2026-06-01 01:39:02 +08:00
mirivlad a4ae22c445 File manager: selection, keyboard shortcuts, rename modal, security, localization
- Selection model: click=select, dblclick=open, Ctrl+click toggle,
  Shift+click range select, Ctrl+A, Esc to clear
- Keyboard shortcuts: Enter, Ctrl+Enter, F2, Backspace (navigate up),
  Delete/Backspace (delete with confirm)
- Rename modal replaces prompt() with validation via backend ValidateName
- Context menu: Open, Open External, Show in Folder, Rename, Duplicate,
  Copy, Cut, Delete — all with SVG icons and Russian labels
- Backend security: vaultPath/absPathSafe helpers prevent path traversal,
  validateName rejects .. / \ null bytes empty overlong names
- MoveNode auto-renames on name conflict (copy style)
- Duplicate uses (copy) (copy 2) suffix pattern
- Russian localization: all file type labels, preview messages, tooltips
- FilePreviewModal: fixed broken {/if} tag
2026-06-01 01:35:45 +08:00
14 changed files with 552 additions and 135 deletions
+35
View File
@@ -143,6 +143,15 @@ func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
return toNodeDTOs(list), nil return toNodeDTOs(list), nil
} }
// ListTodayView returns nodes created or updated today — a dynamic day view.
func (a *App) ListTodayView() ([]NodeDTO, error) {
list, err := a.nodes.ListTodayNodes()
if err != nil {
return nil, err
}
return toNodeDTOs(list), nil
}
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) { func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
list, err := a.nodes.ListChildren(parentID, false) list, err := a.nodes.ListChildren(parentID, false)
if err != nil { if err != nil {
@@ -161,6 +170,9 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
} }
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) { func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
if section == "today" || section == "inbox" {
return nil, fmt.Errorf("cannot create node with section %q", section)
}
n, err := a.nodes.Create(parentID, nodeType, title, section) n, err := a.nodes.Create(parentID, nodeType, title, section)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -315,7 +327,30 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
return a.nodes.UpdateTitle(nodeID, newTitle) return a.nodes.UpdateTitle(nodeID, newTitle)
} }
func (a *App) ValidateName(name string) error {
return files.ValidateName(name)
}
func (a *App) MoveNode(nodeID, newParentID string) error { func (a *App) MoveNode(nodeID, newParentID string) error {
// 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
}
}
return a.nodes.Move(nodeID, newParentID, 0) return a.nodes.Move(nodeID, newParentID, 0)
} }
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
+2 -2
View File
@@ -16,8 +16,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script> <script type="module" crossorigin src="/assets/main-BY9JF_6I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css"> <link rel="stylesheet" crossorigin href="/assets/main-Bo58X7Pc.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+148 -21
View File
@@ -72,6 +72,11 @@
let confirmAction = null let confirmAction = null
let cancelAction = null let cancelAction = null
let showRename = false
let renameId = ''
let renameValue = ''
let renameError = ''
const tabs = [ const tabs = [
{ id: 'overview', label: 'Обзор' }, { id: 'overview', label: 'Обзор' },
{ id: 'notes', label: 'Заметки' }, { id: 'notes', label: 'Заметки' },
@@ -130,7 +135,11 @@
showCreateNode = false showCreateNode = false
error = '' error = ''
try { try {
if (id === 'today') {
nodes = await wailsCall('ListTodayView') || []
} else {
nodes = await wailsCall('ListNodesBySection', id) || [] nodes = await wailsCall('ListNodesBySection', id) || []
}
} catch (e) { } catch (e) {
error = String(e) error = String(e)
nodes = [] nodes = []
@@ -282,19 +291,8 @@
} }
function renameItem(id) { function renameItem(id) {
const name = prompt('Новое имя:') const item = fileItems.find(x => x.id === id)
if (!name || !name.trim()) return if (item) openRename(item.id, item.name)
renameSubmit(id, name.trim())
}
async function renameSubmit(id, newName) {
try {
await wailsCall('RenameNode', id, newName)
const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId)
} catch (e) {
error = String(e)
}
} }
function cutItem(id) { function cutItem(id) {
@@ -343,6 +341,20 @@
selectedIds = fileItems.map(x => x.id) selectedIds = fileItems.map(x => x.id)
} }
function rangeSelect(id) {
if (fileItems.length === 0) return
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
const lastIdx = fileItems.findIndex(x => x.id === lastId)
const curIdx = fileItems.findIndex(x => x.id === id)
if (lastIdx === -1 || curIdx === -1) return
const start = Math.min(lastIdx, curIdx)
const end = Math.max(lastIdx, curIdx)
const range = fileItems.slice(start, end + 1).map(x => x.id)
const set = new Set(selectedIds)
range.forEach(x => set.add(x))
selectedIds = [...set]
}
function clearSelection() { function clearSelection() {
selectedIds = [] selectedIds = []
} }
@@ -421,13 +433,104 @@
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
if (e.key === 'c') { e.preventDefault(); copySelected() } if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
else if (e.key === 'x') { e.preventDefault(); cutSelected() } else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
else if (e.key === 'v') { e.preventDefault(); pasteItem() } else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
else if (e.key === 'a') { e.preventDefault(); selectAll() } else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
} else if (e.key === 'Enter') {
e.preventDefault()
openSelected()
} else if (e.key === 'Delete' || e.key === 'Backspace') { } else if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected() } if (previewItem) { e.preventDefault(); closePreview(); return }
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
// Backspace without selection → navigate up
if (e.key === 'Backspace' && folderStack.length > 0) {
e.preventDefault()
navigateBack()
return
} }
} else if (e.key === 'Escape') {
if (previewItem) { closePreview(); return }
if (selectedIds.length > 0) { clearSelection(); return }
} else if (e.key === 'F2') {
e.preventDefault()
openRenameForSelection()
}
}
function openSelected() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
if (item.type === 'folder') {
navigateToFolder(item.id)
} else {
openPreview(item)
}
}
}
}
function openSelectedExternal() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item && item.fileId) {
wailsCall('OpenFile', item.fileId)
}
}
}
// ===== Rename modal =====
function openRename(id, currentName) {
renameId = id
renameValue = currentName
renameError = ''
showRename = true
}
function openRenameForSelection() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
openRename(item.id, item.name)
}
}
}
async function submitRename() {
const name = renameValue.trim()
if (!name) { renameError = 'Имя не может быть пустым'; return }
// Validate name via backend
try {
await wailsCall('ValidateName', name)
} catch (e) {
renameError = 'Недопустимое имя'
return
}
showRename = false
renameId = ''
try {
await wailsCall('RenameNode', renameId, name)
const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId)
} catch (e) {
error = String(e)
}
}
function cancelRename() {
showRename = false
renameId = ''
renameValue = ''
renameError = ''
}
function onRenameKeydown(e) {
if (e.key === 'Enter') submitRename()
else renameError = ''
} }
// ===== Confirm modal ===== // ===== Confirm modal =====
@@ -832,7 +935,7 @@
</div> </div>
{:else} {:else}
{#if folderStack.length > 0} {#if folderStack.length > 0}
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => { <FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => {
const i = e.detail const i = e.detail
if (i === 0) { if (i === 0) {
folderStack = [] folderStack = []
@@ -847,7 +950,7 @@
Back Back
</button> </button>
{:else} {:else}
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/> <FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/>
{/if} {/if}
{#if fileItems.length === 0} {#if fileItems.length === 0}
@@ -877,6 +980,7 @@
on:navigate={(e) => navigateToFolder(e.detail)} on:navigate={(e) => navigateToFolder(e.detail)}
on:preview={(e) => openPreview(e.detail)} on:preview={(e) => openPreview(e.detail)}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)} on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
on:delete={(e) => deleteFile(e.detail)} on:delete={(e) => deleteFile(e.detail)}
on:rename={(e) => renameItem(e.detail.id)} on:rename={(e) => renameItem(e.detail.id)}
on:duplicate={(e) => duplicateItem(e.detail)} on:duplicate={(e) => duplicateItem(e.detail)}
@@ -884,6 +988,7 @@
on:copy={(e) => copyItem(e.detail)} on:copy={(e) => copyItem(e.detail)}
on:selectOne={(e) => selectOne(e.detail)} on:selectOne={(e) => selectOne(e.detail)}
on:toggleSelect={(e) => toggleSelection(e.detail)} on:toggleSelect={(e) => toggleSelection(e.detail)}
on:rangeSelect={(e) => rangeSelect(e.detail)}
/> />
{/each} {/each}
</div> </div>
@@ -954,7 +1059,7 @@
</div> </div>
{/if} {/if}
{#if !noteEditor && !selectedNode} {#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox'}
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div> <div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
{/if} {/if}
@@ -1007,6 +1112,26 @@
</div> </div>
{/if} {/if}
{#if showRename}
<div class="modal-overlay" on:click|self={cancelRename}>
<div class="modal">
<h3>Переименовать</h3>
<div class="form-group">
<label>Новое имя</label>
<input type="text" bind:value={renameValue}
on:keydown={onRenameKeydown} />
</div>
{#if renameError}
<div class="rename-error">{renameError}</div>
{/if}
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button>
<button class="btn" on:click={cancelRename}>Отмена</button>
</div>
</div>
</div>
{/if}
{#if showConfirm} {#if showConfirm}
<ConfirmModal <ConfirmModal
title={confirmTitle} title={confirmTitle}
@@ -1159,4 +1284,6 @@
.import-summary { margin-bottom: 16px; } .import-summary { margin-bottom: 16px; }
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; } .summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; } .summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
</style> </style>
+92 -46
View File
@@ -1,7 +1,7 @@
<script> <script>
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import FileIcon from './lib/FileIcon.svelte' import FileIcon from './lib/FileIcon.svelte'
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js' import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
export let item export let item
export let selected = false export let selected = false
@@ -13,6 +13,7 @@
const kind = getFileKind(item) const kind = getFileKind(item)
const isFolder = item.type === 'folder' const isFolder = item.type === 'folder'
const fileType = formatFileType(item)
let menuOpen = false let menuOpen = false
let clickTimer = null let clickTimer = null
@@ -25,7 +26,7 @@
if (clickTimer) { if (clickTimer) {
clearTimeout(clickTimer) clearTimeout(clickTimer)
clickTimer = null clickTimer = null
// Double click // Double click → открыть
if (isFolder) { if (isFolder) {
dispatch('navigate', item.id) dispatch('navigate', item.id)
} else { } else {
@@ -34,37 +35,23 @@
} else { } else {
clickTimer = setTimeout(() => { clickTimer = setTimeout(() => {
clickTimer = null clickTimer = null
// Single click: select // Single click → выделить
if (selected) {
// Already selected: navigate/preview
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
} else {
dispatch('selectOne', item.id) dispatch('selectOne', item.id)
}
}, 250) }, 250)
} }
} }
} }
function handleKeydown(e) {
if (e.key === 'Enter') {
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
}
}
function handleOpenExternal() { function handleOpenExternal() {
dispatch('openExternal', item.fileId) dispatch('openExternal', item.fileId)
} }
function handleShowInFolder() {
dispatch('showInFolder', item.id)
}
function handleDelete() { function handleDelete() {
menuOpen = false
dispatch('delete', { id: item.id, type: item.type }) dispatch('delete', { id: item.id, type: item.type })
} }
@@ -88,6 +75,15 @@
dispatch('copy', item.id) dispatch('copy', item.id)
} }
function handleOpen() {
menuOpen = false
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
}
function toggleMenu() { function toggleMenu() {
menuOpen = !menuOpen menuOpen = !menuOpen
} }
@@ -107,48 +103,48 @@
function handleDrop(e) { function handleDrop(e) {
if (onDrop && isFolder) onDrop(e, item.id) if (onDrop && isFolder) onDrop(e, item.id)
} }
function oncontextmenu(e) {
e.preventDefault()
menuOpen = true
}
</script> </script>
<svelte:window on:click={closeMenu}/> <svelte:window on:click={closeMenu}/>
<div class="file-row" <div class="file-row"
class:file-row--selected={selected} class:file-row--selected={selected}
class:file-row--dragover={false}
role="button" role="button"
tabindex="0" tabindex="0"
draggable="true" draggable="true"
on:click={handleClick} on:click={handleClick}
on:keydown={handleKeydown} on:contextmenu={oncontextmenu}
on:dragstart={handleDragStart} on:dragstart={handleDragStart}
on:dragover={handleDragOver} on:dragover={handleDragOver}
on:drop={handleDrop} on:drop={handleDrop}
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}> aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
<div class="file-row-icon"> <div class="file-row-icon">
<FileIcon {kind} size={22}/> <FileIcon {kind} size={22}/>
</div> </div>
<div class="file-row-body"> <div class="file-row-body">
<div class="file-row-name" title={item.name}>{item.name}</div> <div class="file-row-name" title={item.name}>{item.name}</div>
<div class="file-row-meta"> <div class="file-row-meta">
{#if isFolder} <span>{fileType}</span>
<span>Folder</span> {#if !isFolder}
{:else}
<span>{formatFileSize(item.size)}</span>
{#if item.mime}
<span class="meta-sep">·</span> <span class="meta-sep">·</span>
<span>{formatMimeType(item.mime)}</span> <span>{formatFileSize(item.size)}</span>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
<div class="file-row-actions"> <div class="file-row-actions">
{#if !isFolder} {#if !isFolder}
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Preview" aria-label="Preview"> <button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/> <circle cx="12" cy="12" r="3"/>
</svg> </svg>
</button> </button>
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Open in external program" aria-label="Open externally"> <button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/> <polyline points="15 3 21 3 21 9"/>
@@ -156,21 +152,21 @@
</svg> </svg>
</button> </button>
{:else} {:else}
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder"> <button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/> <line x1="9" y1="14" x2="15" y2="14"/>
</svg> </svg>
</button> </button>
{/if} {/if}
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="More actions" aria-label="More actions" aria-expanded={menuOpen}> <button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/> <circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/> <circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/> <circle cx="12" cy="19" r="2"/>
</svg> </svg>
</button> </button>
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Delete" aria-label="Delete"> <button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/> <polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
@@ -183,10 +179,42 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div> <div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
<div class="menu" on:click|stopPropagation role="menu"> <div class="menu" on:click|stopPropagation role="menu">
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button> <button class="menu-item" on:click={handleOpen} role="menuitem">
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<button class="menu-item" on:click={handleCut} role="menuitem">Cut</button> Открыть
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button> </button>
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Открыть во внешней программе
</button>
{#if isFolder}
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
Показать в проводнике
</button>
{/if}
<div class="menu-sep"></div>
<button class="menu-item" on:click={handleRename} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Переименовать
</button>
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Дублировать
</button>
<button class="menu-item" on:click={handleCut} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
Вырезать
</button>
<button class="menu-item" on:click={handleCopy} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Копировать
</button>
<div class="menu-sep"></div>
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Удалить
</button>
</div> </div>
{/if} {/if}
@@ -201,6 +229,7 @@
transition: background 0.12s; transition: background 0.12s;
min-height: 52px; min-height: 52px;
user-select: none; user-select: none;
position: relative;
} }
.file-row:hover { .file-row:hover {
@@ -314,17 +343,19 @@
margin-top: 4px; margin-top: 4px;
background: #1a1a28; background: #1a1a28;
border: 1px solid #2a2a3c; border: 1px solid #2a2a3c;
border-radius: 6px; border-radius: 8px;
padding: 4px; padding: 4px;
z-index: 100; z-index: 100;
min-width: 140px; min-width: 220px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4); box-shadow: 0 4px 16px rgba(0,0,0,0.5);
} }
.menu-item { .menu-item {
display: block; display: flex;
align-items: center;
gap: 8px;
width: 100%; width: 100%;
padding: 6px 12px; padding: 7px 10px;
border: none; border: none;
background: transparent; background: transparent;
color: #ccc; color: #ccc;
@@ -337,10 +368,25 @@
.menu-item:hover { .menu-item:hover {
background: #2a2a3c; background: #2a2a3c;
color: #fff;
}
.menu-item-danger {
color: #ff6b6b;
}
.menu-item-danger:hover {
background: #3a2222;
} }
.menu-item:focus-visible { .menu-item:focus-visible {
outline: 2px solid #5588ff; outline: 2px solid #5588ff;
outline-offset: 1px; outline-offset: 1px;
} }
.menu-sep {
height: 1px;
background: #2a2a3c;
margin: 4px 8px;
}
</style> </style>
+2
View File
@@ -3,6 +3,7 @@ import * as App from '../wailsjs/go/main/App.js'
// Re-export all methods // Re-export all methods
export const listSections = () => App.ListSections() export const listSections = () => App.ListSections()
export const listTodayView = () => App.ListTodayView()
export const listNodesBySection = (section) => App.ListNodesBySection(section) export const listNodesBySection = (section) => App.ListNodesBySection(section)
export const listChildren = (parentID) => App.ListChildren(parentID) export const listChildren = (parentID) => App.ListChildren(parentID)
export const getNodeDetail = (id) => App.GetNodeDetail(id) export const getNodeDetail = (id) => App.GetNodeDetail(id)
@@ -31,6 +32,7 @@ export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle) export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID) export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
export const openFolder = (nodeID) => App.OpenFolder(nodeID) export const openFolder = (nodeID) => App.OpenFolder(nodeID)
export const validateName = (name) => App.ValidateName(name)
export const listActions = (nodeID) => App.ListActions(nodeID) export const listActions = (nodeID) => App.ListActions(nodeID)
export const runAction = (id) => App.RunAction(id) export const runAction = (id) => App.RunAction(id)
+7 -7
View File
@@ -43,7 +43,7 @@
</div> </div>
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div> <div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
<div class="preview-actions"> <div class="preview-actions">
<button class="action-btn" on:click={handleOpenExternal} title="Open in external program" aria-label="Open externally"> <button class="action-btn" on:click={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/> <polyline points="15 3 21 3 21 9"/>
@@ -60,11 +60,11 @@
</header> </header>
<div class="preview-body"> <div class="preview-body">
{#if loading} {#if loading}
<div class="preview-status"><p>Loading preview...</p></div> <div class="preview-status"><p>Загрузка...</p></div>
{:else if error} {:else if error}
<div class="preview-status"> <div class="preview-status">
<p>{error}</p> <p>{error}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button> <button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div> </div>
{:else if showImage && content} {:else if showImage && content}
<div class="preview-image-container"> <div class="preview-image-container">
@@ -79,14 +79,14 @@
</div> </div>
{:else} {:else}
<div class="preview-status"> <div class="preview-status">
<p>PDF preview is not available in this environment.</p> <p>Предпросмотр PDF недоступен.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button> <button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div> </div>
{/if} {/if}
{:else} {:else}
<div class="preview-status"> <div class="preview-status">
<p>Preview is not available for this file type.</p> <p>Предпросмотр недоступен для этого типа файлов.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button> <button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div> </div>
{/if} {/if}
</div> </div>
+41 -31
View File
@@ -8,43 +8,53 @@ export function formatFileSize(bytes) {
} }
const mimeLabels = { const mimeLabels = {
'image/jpeg': 'JPEG image', 'image/jpeg': 'Изображение JPEG',
'image/png': 'PNG image', 'image/png': 'Изображение PNG',
'image/gif': 'GIF image', 'image/gif': 'Изображение GIF',
'image/webp': 'WebP image', 'image/webp': 'Изображение WebP',
'image/svg+xml': 'SVG image', 'image/svg+xml': 'Изображение SVG',
'image/bmp': 'BMP image', 'image/bmp': 'Изображение BMP',
'image/tiff': 'TIFF image', 'image/tiff': 'Изображение TIFF',
'image/avif': 'AVIF image', 'image/avif': 'Изображение AVIF',
'application/pdf': 'PDF document', 'application/pdf': 'PDF документ',
'application/msword': 'Word document', 'application/msword': 'Документ Word',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
'application/vnd.ms-excel': 'Excel spreadsheet', 'application/vnd.ms-excel': 'Таблица Excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
'application/vnd.ms-powerpoint': 'PowerPoint presentation', 'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
'application/zip': 'ZIP archive', 'application/zip': 'ZIP архив',
'application/gzip': 'GZIP archive', 'application/gzip': 'GZIP архив',
'application/x-tar': 'TAR archive', 'application/x-tar': 'TAR архив',
'application/x-7z-compressed': '7z archive', 'application/x-7z-compressed': '7z архив',
'application/x-rar-compressed': 'RAR archive', 'application/x-rar-compressed': 'RAR архив',
'text/plain': 'Text file', 'text/plain': 'Текстовый файл',
'text/html': 'HTML file', 'text/html': 'HTML файл',
'text/css': 'CSS file', 'text/css': 'CSS файл',
'text/javascript': 'JavaScript file', 'text/javascript': 'JavaScript файл',
'application/json': 'JSON file', 'application/json': 'JSON файл',
'application/xml': 'XML file', 'application/xml': 'XML файл',
'application/x-yaml': 'YAML file', 'application/x-yaml': 'YAML файл',
'application/octet-stream': 'Binary file', 'application/octet-stream': 'Бинарный файл',
'application/x-msdos-program': 'Executable', 'application/x-msdos-program': 'Исполняемый файл',
'inode/directory': 'Folder', 'inode/directory': 'Папка',
} }
export function formatMimeType(mime) { export function formatMimeType(mime) {
if (!mime) return 'Unknown' if (!mime) return 'Неизвестно'
return mimeLabels[mime] || mime return mimeLabels[mime] || mime
} }
export function formatFileType(item) {
if (item.type === 'folder') return 'Папка'
const mime = (item.mime || '').toLowerCase()
if (mimeLabels[mime]) return mimeLabels[mime]
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
if (ext) return ext.toUpperCase()
return 'Файл'
}
export function getFileKind(item) { export function getFileKind(item) {
if (item.type === 'folder') return 'folder' if (item.type === 'folder') return 'folder'
const mime = (item.mime || '').toLowerCase() const mime = (item.mime || '').toLowerCase()
+4
View File
@@ -6,6 +6,10 @@ export function ListSections() {
return window['go']['main']['App']['ListSections'](); return window['go']['main']['App']['ListSections']();
} }
export function ListTodayView() {
return window['go']['main']['App']['ListTodayView']();
}
export function ListNodesBySection(arg1) { export function ListNodesBySection(arg1) {
return window['go']['main']['App']['ListNodesBySection'](arg1); return window['go']['main']['App']['ListNodesBySection'](arg1);
} }
+120 -18
View File
@@ -60,6 +60,59 @@ func (s *Service) DB() *storage.DB {
return s.db return s.db
} }
// --- security helpers ---
// ValidateName is an exported wrapper for validateName.
func ValidateName(name string) error {
return validateName(name)
}
// validateName rejects filenames with path separators, relative components,
// and other dangerous patterns.
func validateName(name string) error {
if name == "" {
return fmt.Errorf("name is required")
}
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
return fmt.Errorf("name must not contain path separators")
}
if strings.Contains(name, "..") {
return fmt.Errorf("name must not contain '..'")
}
if strings.Contains(name, "\x00") {
return fmt.Errorf("name must not contain null bytes")
}
if len(name) > 255 {
return fmt.Errorf("name too long (max 255)")
}
return nil
}
// vaultPath resolves a relative vault path and checks it stays within jail.
func (s *Service) vaultPath(rel string) (string, error) {
abs := filepath.Join(s.vaultRoot, rel)
cleaned := filepath.Clean(abs)
if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) {
return "", fmt.Errorf("path escapes vault root")
}
return cleaned, nil
}
// absPathSafe resolves an absolute path and checks jail if it's under vault.
// For "external" mode records the path stored may be an absolute external path.
// This function only checks path safety — it does not enforce that external
// files must be inside the vault.
func (s *Service) absPathSafe(rec *Record) (string, error) {
if rec.StorageMode == "vault" {
return s.vaultPath(rec.Path)
}
abs, err := filepath.Abs(rec.Path)
if err != nil {
return "", fmt.Errorf("abs: %w", err)
}
return filepath.Clean(abs), nil
}
// --- public operations --- // --- public operations ---
// AddExternal registers an external file (absolute path) without copying. // AddExternal registers an external file (absolute path) without copying.
@@ -84,6 +137,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
} }
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug) destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
if _, err := s.vaultPath(filepath.Join("spaces", nodeSlug)); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
if err := os.MkdirAll(destDir, 0o750); err != nil { if err := os.MkdirAll(destDir, 0o750); err != nil {
return nil, fmt.Errorf("mkdir: %w", err) return nil, fmt.Errorf("mkdir: %w", err)
} }
@@ -105,6 +161,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
} }
relPath, _ := filepath.Rel(s.vaultRoot, dest) relPath, _ := filepath.Rel(s.vaultRoot, dest)
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash) return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
} }
@@ -149,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
return err return err
} }
if rec.StorageMode == "vault" { if rec.StorageMode == "vault" {
src := filepath.Join(s.vaultRoot, rec.Path) src, err := s.vaultPath(rec.Path)
if err != nil {
return err
}
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash") trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
if err := os.MkdirAll(trashDir, 0o750); err != nil { if err := os.MkdirAll(trashDir, 0o750); err != nil {
return err return err
} }
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename) dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
// verify trash is inside vault
if _, err := s.vaultPath(filepath.Join(".verstak", "trash", rec.ID+"_"+rec.Filename)); err != nil {
return err
}
if err := os.Rename(src, dest); err != nil { if err := os.Rename(src, dest); err != nil {
return fmt.Errorf("move to trash: %w", err) return fmt.Errorf("move to trash: %w", err)
} }
@@ -169,11 +235,9 @@ func (s *Service) Open(id string) error {
if err != nil { if err != nil {
return err return err
} }
var abs string abs, err := s.absPathSafe(rec)
if rec.StorageMode == "vault" { if err != nil {
abs = filepath.Join(s.vaultRoot, rec.Path) return err
} else {
abs = rec.Path
} }
return openWithSystem(abs) return openWithSystem(abs)
} }
@@ -190,11 +254,9 @@ func (s *Service) ReadText(id string) (string, error) {
if rec.Size > maxPreviewSize { if rec.Size > maxPreviewSize {
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size) return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
} }
var abs string abs, err := s.absPathSafe(rec)
if rec.StorageMode == "vault" { if err != nil {
abs = filepath.Join(s.vaultRoot, rec.Path) return "", err
} else {
abs = rec.Path
} }
b, err := os.ReadFile(abs) b, err := os.ReadFile(abs)
if err != nil { if err != nil {
@@ -212,11 +274,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
if rec.Size > maxPreviewSize { if rec.Size > maxPreviewSize {
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size) return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
} }
var abs string abs, err := s.absPathSafe(rec)
if rec.StorageMode == "vault" { if err != nil {
abs = filepath.Join(s.vaultRoot, rec.Path) return "", err
} else {
abs = rec.Path
} }
b, err := os.ReadFile(abs) b, err := os.ReadFile(abs)
if err != nil { if err != nil {
@@ -231,6 +291,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
// CreateEmptyFile creates a file node and an empty vault file. // CreateEmptyFile creates a file node and an empty vault file.
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) { func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
if err := validateName(filename); err != nil {
return nil, fmt.Errorf("invalid filename: %w", err)
}
filename = s.uniqueTitle(parentID, filename) filename = s.uniqueTitle(parentID, filename)
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "") node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
if err != nil { if err != nil {
@@ -247,6 +310,10 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error
} }
f.Close() f.Close()
relPath, _ := filepath.Rel(s.vaultRoot, dest) relPath, _ := filepath.Rel(s.vaultRoot, dest)
// Verify dest is inside vault
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "") _, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
return node, err return node, err
} }
@@ -261,7 +328,7 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
if original.ParentID != nil { if original.ParentID != nil {
parentID = *original.ParentID parentID = *original.ParentID
} }
newName := s.uniqueTitle(parentID, original.Title) newName := s.copyTitle(parentID, original.Title)
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section) node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -271,7 +338,10 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
if len(records) > 0 { if len(records) > 0 {
src := &records[0] src := &records[0]
if src.StorageMode == "vault" { if src.StorageMode == "vault" {
srcPath := filepath.Join(s.vaultRoot, src.Path) srcPath, err := s.vaultPath(src.Path)
if err != nil {
return nil, err
}
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug) dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
os.MkdirAll(dir, 0o750) os.MkdirAll(dir, 0o750)
dst := filepath.Join(dir, newName) dst := filepath.Join(dir, newName)
@@ -280,6 +350,9 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
return nil, fmt.Errorf("copy file: %w", err) return nil, fmt.Errorf("copy file: %w", err)
} }
relPath, _ := filepath.Rel(s.vaultRoot, dst) relPath, _ := filepath.Rel(s.vaultRoot, dst)
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash) _, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -452,6 +525,35 @@ func (s *Service) uniqueTitle(parentID, desired string) string {
} }
} }
// copyTitle generates a unique "Name (copy).ext" style name for duplicates.
// For files with extensions: "photo.jpg" → "photo (copy).jpg", "photo (copy 2).jpg"
// For folders: "Docs" → "Docs (copy)", "Docs (copy 2)"
func (s *Service) copyTitle(parentID, desired string) string {
children, _ := s.nodes.ListChildren(parentID, false)
used := make(map[string]bool, len(children))
for i := range children {
used[children[i].Title] = true
}
ext := filepath.Ext(desired)
base := strings.TrimSuffix(desired, ext)
copyName := base + " (copy)" + ext
if !used[copyName] {
return copyName
}
for n := 2; ; n++ {
candidate := fmt.Sprintf("%s (copy %d)%s", base, n, ext)
if !used[candidate] {
return candidate
}
}
}
// UniqueTitleCopy returns a copy-style unique name for use in conflict resolution.
func (s *Service) UniqueTitleCopy(parentID, desired string) string {
return s.copyTitle(parentID, desired)
}
// --- implementation details --- // --- implementation details ---
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) { func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
+38 -8
View File
@@ -50,16 +50,10 @@ func now() string {
return time.Now().UTC().Format(time.RFC3339) return time.Now().UTC().Format(time.RFC3339)
} }
// --- CRUD ---
// Valid sections for root-level nodes.
var validSections = map[string]struct{}{
"clients": {}, "projects": {}, "recipes": {}, "documents": {}, "archive": {},
}
// Create inserts a root or child node. // Create inserts a root or child node.
// parentID may be empty for root-level nodes. // parentID may be empty for root-level nodes.
// For root nodes, section determines sidebar placement (may be empty → inbox). // For root nodes, section determines sidebar placement (may be empty = inbox).
// section must be a valid section (clients, projects, etc.) or empty for inbox.
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) { func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
if !IsValidType(typ) { if !IsValidType(typ) {
return nil, fmt.Errorf("invalid node type: %s", typ) return nil, fmt.Errorf("invalid node type: %s", typ)
@@ -67,6 +61,9 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
if title == "" { if title == "" {
return nil, errors.New("title is required") return nil, errors.New("title is required")
} }
if section != "" && !IsValidSection(section) {
return nil, fmt.Errorf("invalid section: %s", section)
}
n := &Node{ n := &Node{
ID: util.UUID7(), ID: util.UUID7(),
@@ -182,6 +179,39 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
return scanNodes(rows) return scanNodes(rows)
} }
// todayBoundaries returns RFC3339 start and end strings for the current day
// in the local timezone (the server's timezone, which should match the user's).
// TODO: accept a user timezone offset when multi-user support is added.
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.Format(time.RFC3339), end.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.
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 (
(created_at >= ? AND created_at < ?)
OR
(updated_at >= ? AND updated_at < ?)
)
ORDER BY updated_at DESC, created_at DESC`
rows, err := r.db.Query(q, start, end, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
return scanNodes(rows)
}
// UpdateTitle changes title (slug is recomputed). // UpdateTitle changes title (slug is recomputed).
func (r *Repository) UpdateTitle(id, title string) error { func (r *Repository) UpdateTitle(id, title string) error {
if title == "" { if title == "" {
+28
View File
@@ -35,12 +35,40 @@ var TypeSet = map[string]struct{}{
TypeLink: {}, TypeLink: {},
} }
// Valid sections for root-level nodes.
// today and inbox are service sections, not stored in nodes.section.
var validSections = map[string]struct{}{
"clients": {},
"projects": {},
"recipes": {},
"documents": {},
"archive": {},
}
// serviceSections are sidebar entries that are not stored as node sections.
var serviceSections = map[string]struct{}{
"today": {},
"inbox": {},
}
// IsValidType checks whether a type string is recognized. // IsValidType checks whether a type string is recognized.
func IsValidType(t string) bool { func IsValidType(t string) bool {
_, ok := TypeSet[t] _, ok := TypeSet[t]
return ok return ok
} }
// IsValidSection returns true for sections that can be stored on a node.
func IsValidSection(s string) bool {
_, ok := validSections[s]
return ok
}
// IsServiceSection returns true for sidebar-only sections (today, inbox).
func IsServiceSection(s string) bool {
_, ok := serviceSections[s]
return ok
}
// Slugify converts a title into a filesystem-safe slug. // Slugify converts a title into a filesystem-safe slug.
// Examples: // Examples:
// //