- set_entry removes entry from old bucket when type changes (fixes duplicates) - PUT /api/entries/:id now accepts type field - Tag clicks navigate via URL (#type/x/tag/y or #tag/y) instead of mutating state - Replace magic 'all' hash with empty hash as the all-entries route - Clickable tags in entry detail view - Tag filter dropdown updates URL Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
162 lines
5.1 KiB
JavaScript
162 lines
5.1 KiB
JavaScript
import { mkdirSync } from 'node:fs';
|
|
import { Simple_KeyValue_Store } from './kv-store.mjs';
|
|
|
|
mkdirSync('./data', { recursive: true });
|
|
|
|
const store = new Simple_KeyValue_Store('./data/tasks.ndjson', {
|
|
auto_load: true,
|
|
auto_store: true,
|
|
debounce_flush_timeout: 5000,
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Migration from old flat-key format (task:1, task:2, ...)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function migrate_if_needed() {
|
|
const old_keys = [];
|
|
for (const [key] of store.data.entries()) {
|
|
if (key.startsWith('task:')) { old_keys.push(key); }
|
|
}
|
|
if (!old_keys.length) { return; }
|
|
|
|
console.log(`[storage] Migrating ${old_keys.length} old flat-key entries...`);
|
|
|
|
const task_bucket = store.get('task') ?? {};
|
|
let max_id = store.get('meta')?.next_id ?? 0;
|
|
|
|
for (const key of old_keys) {
|
|
const entry = store.get(key);
|
|
task_bucket[entry.id] = { ...entry, type: 'task' };
|
|
if (typeof entry.id === 'number' && entry.id > max_id) { max_id = entry.id; }
|
|
store.data.delete(key);
|
|
}
|
|
|
|
store.set('task', task_bucket);
|
|
|
|
// Absorb old ids.ndjson counter if present
|
|
try {
|
|
const ids_store = new Simple_KeyValue_Store('./data/ids.ndjson', { auto_load: true });
|
|
const old_counter = ids_store.get('seq:task');
|
|
if (old_counter && old_counter > max_id) { max_id = old_counter; }
|
|
} catch {}
|
|
|
|
store.set('meta', { ...(store.get('meta') ?? {}), next_id: max_id });
|
|
store.store();
|
|
console.log(`[storage] Migration complete. next_id=${max_id}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Seed default entry types on a fresh store
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function seed_defaults() {
|
|
const now = Date.now();
|
|
store.set('entry_types', {
|
|
task: { id: 'task', title: 'Task', description: 'A unit of work to be completed.', created_at: now, updated_at: now },
|
|
idea: { id: 'idea', title: 'Idea', description: 'A thought, concept, or possibility to explore.', created_at: now, updated_at: now },
|
|
note: { id: 'note', title: 'Note', description: 'A reference, observation, or piece of information.', created_at: now, updated_at: now },
|
|
});
|
|
if (!store.get('task')) { store.set('task', {}); }
|
|
if (!store.get('idea')) { store.set('idea', {}); }
|
|
if (!store.get('note')) { store.set('note', {}); }
|
|
store.store();
|
|
}
|
|
|
|
migrate_if_needed();
|
|
if (!store.get('entry_types')) { seed_defaults(); }
|
|
if (!store.get('meta')) { store.set('meta', { next_id: 0 }); }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ID generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function next_id() {
|
|
const meta = store.get('meta') ?? { next_id: 0 };
|
|
meta.next_id = (meta.next_id ?? 0) + 1;
|
|
store.set('meta', meta);
|
|
return meta.next_id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function list_entry_types() {
|
|
return Object.values(store.get('entry_types') ?? {});
|
|
}
|
|
|
|
export function get_entry_type(id) {
|
|
return (store.get('entry_types') ?? {})[id] ?? null;
|
|
}
|
|
|
|
export function set_entry_type(type) {
|
|
const types = store.get('entry_types') ?? {};
|
|
types[type.id] = type;
|
|
store.set('entry_types', types);
|
|
if (!store.get(type.id)) { store.set(type.id, {}); }
|
|
}
|
|
|
|
export function delete_entry_type(id) {
|
|
const types = store.get('entry_types') ?? {};
|
|
if (!types[id]) { return false; }
|
|
delete types[id];
|
|
store.set('entry_types', types);
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function list_entries(type = null) {
|
|
const type_keys = type ? [type] : Object.keys(store.get('entry_types') ?? {});
|
|
const result = [];
|
|
for (const t of type_keys) {
|
|
result.push(...Object.values(store.get(t) ?? {}));
|
|
}
|
|
return result.sort((a, b) => b.created_at - a.created_at);
|
|
}
|
|
|
|
export function get_entry(id) {
|
|
for (const t of Object.keys(store.get('entry_types') ?? {})) {
|
|
const entry = (store.get(t) ?? {})[id];
|
|
if (entry) { return entry; }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function set_entry(entry) {
|
|
// Remove from any other bucket in case type changed
|
|
for (const t of Object.keys(store.get('entry_types') ?? {})) {
|
|
if (t === entry.type) { continue; }
|
|
const bucket = store.get(t);
|
|
if (bucket?.[entry.id]) {
|
|
delete bucket[entry.id];
|
|
store.set(t, bucket);
|
|
}
|
|
}
|
|
const bucket = store.get(entry.type) ?? {};
|
|
bucket[entry.id] = entry;
|
|
store.set(entry.type, bucket);
|
|
}
|
|
|
|
export function delete_entry(id) {
|
|
for (const t of Object.keys(store.get('entry_types') ?? {})) {
|
|
const bucket = store.get(t);
|
|
if (bucket?.[id]) {
|
|
delete bucket[id];
|
|
store.set(t, bucket);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function has_child_entries(parent_id) {
|
|
for (const t of Object.keys(store.get('entry_types') ?? {})) {
|
|
if (Object.values(store.get(t) ?? {}).some(e => e.parent_id === parent_id)) { return true; }
|
|
}
|
|
return false;
|
|
}
|