Change the data model.
This commit is contained in:
parent
1c6aa158c2
commit
1218cf3f8e
2 changed files with 163 additions and 172 deletions
18
TODO.md
18
TODO.md
|
|
@ -2,23 +2,15 @@
|
|||
|
||||
## TODO
|
||||
|
||||
- [ ] Don't put carriage returns between individual TODO list items. As far as possible try to preserve original formatting, just moving individual lines around without disturbing other lines.
|
||||
|
||||
- [ ] Ability to create a project on the projects select page, including if TODO.md is empty.
|
||||
|
||||
- [ ] Ability to create, move, remove columns.
|
||||
|
||||
- [ ] Plus button in the columns to add a new item, with text input.
|
||||
|
||||
- [ ] Make it look more the makesprite/concrete.css theme.
|
||||
|
||||
- [ ] Minimize the number of times the user is asked for access. If possible ask for all the access required at one time.
|
||||
|
||||
- [x] Give each column a max width of 450px and have them stretch to fill the area up to that.
|
||||
|
||||
- [ ] Secific check type of UI for marking items complete/uncomplete.
|
||||
|
||||
|
||||
- [ ] When dragging between columns we should show the insertion point and insert the card at that point.
|
||||
- [ ] Ability to create, move, remove columns.
|
||||
- [ ] Make it look more the makesprite/concrete.css theme.
|
||||
- [x] Give each column a max width of 450px and have them stretch to fill the area up to that.
|
||||
- [x] Don't put carriage returns between individual TODO list items. As far as possible try to preserve original formatting, just moving individual lines around without disturbing other lines.
|
||||
|
||||
## In progress
|
||||
|
||||
|
|
|
|||
317
index.html
317
index.html
|
|
@ -277,7 +277,7 @@
|
|||
let state = {
|
||||
fileHandle: null,
|
||||
filePath: null,
|
||||
sections: [], // Parsed data
|
||||
sections: [],
|
||||
currentSectionIndex: -1,
|
||||
lastModified: 0,
|
||||
showCompleted: false
|
||||
|
|
@ -318,7 +318,7 @@
|
|||
});
|
||||
}
|
||||
|
||||
// --- Markdown Parsing & Serialization ---
|
||||
// --- Markdown Parsing ---
|
||||
|
||||
function parseMarkdown(text) {
|
||||
const lines = text.split('\n');
|
||||
|
|
@ -327,7 +327,6 @@
|
|||
let currentColumn = null;
|
||||
let currentCard = null;
|
||||
|
||||
// Helper to finalize current card
|
||||
const finishCard = () => {
|
||||
if (currentCard) {
|
||||
if (currentColumn) {
|
||||
|
|
@ -337,8 +336,9 @@
|
|||
}
|
||||
};
|
||||
|
||||
for (let line of lines) {
|
||||
// H1 - New Section
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('# ')) {
|
||||
finishCard();
|
||||
currentSection = {
|
||||
|
|
@ -348,16 +348,15 @@
|
|||
sections.push(currentSection);
|
||||
currentColumn = null;
|
||||
}
|
||||
// H2 - New Column (only if inside a section)
|
||||
else if (line.startsWith('## ') && currentSection) {
|
||||
finishCard();
|
||||
currentColumn = {
|
||||
name: line.substring(3).trim(),
|
||||
headerLineNumber: i,
|
||||
cards: []
|
||||
};
|
||||
currentSection.columns.push(currentColumn);
|
||||
}
|
||||
// Task
|
||||
else if ((line.trim().startsWith('- [ ]') || line.trim().startsWith('- [x]')) && currentColumn) {
|
||||
finishCard();
|
||||
const isCompleted = line.trim().startsWith('- [x]');
|
||||
|
|
@ -367,45 +366,126 @@
|
|||
currentCard = {
|
||||
text: text,
|
||||
completed: isCompleted,
|
||||
lineNumber: i,
|
||||
originalLine: line,
|
||||
nestedLines: []
|
||||
};
|
||||
}
|
||||
// Nested content or other content
|
||||
else {
|
||||
if (currentCard) {
|
||||
currentCard.nestedLines.push(line);
|
||||
}
|
||||
// Ignore content outside of tasks for now, as per spec "ignored"
|
||||
}
|
||||
}
|
||||
finishCard();
|
||||
return sections;
|
||||
}
|
||||
|
||||
function serializeToMarkdown(sections) {
|
||||
let output = [];
|
||||
|
||||
for (const section of sections) {
|
||||
output.push(`# ${section.name}`);
|
||||
output.push(''); // Empty line after header
|
||||
// --- File Operations ---
|
||||
|
||||
async function writeFile(text) {
|
||||
try {
|
||||
const writable = await state.fileHandle.createWritable();
|
||||
await writable.write(text);
|
||||
await writable.close();
|
||||
console.log('File written successfully');
|
||||
|
||||
for (const column of section.columns) {
|
||||
output.push(`## ${column.name}`);
|
||||
output.push('');
|
||||
|
||||
for (const card of column.cards) {
|
||||
const check = card.completed ? 'x' : ' ';
|
||||
output.push(`- [${check}] ${card.text}`);
|
||||
if (card.nestedLines && card.nestedLines.length > 0) {
|
||||
output.push(...card.nestedLines);
|
||||
}
|
||||
}
|
||||
output.push('');
|
||||
}
|
||||
const file = await state.fileHandle.getFile();
|
||||
state.lastModified = file.lastModified;
|
||||
|
||||
reloadFromText(text);
|
||||
} catch (err) {
|
||||
console.error('Error writing file:', err);
|
||||
alert('Error saving file.');
|
||||
}
|
||||
}
|
||||
|
||||
function reloadFromText(text) {
|
||||
console.log('Reloading from text after write');
|
||||
state.sections = parseMarkdown(text);
|
||||
console.log('Parsed sections:', state.sections);
|
||||
|
||||
// Join and ensure single newline at end
|
||||
return output.join('\n').trim() + '\n';
|
||||
if (state.currentSectionIndex !== -1 && state.sections[state.currentSectionIndex]) {
|
||||
renderBoard();
|
||||
} else {
|
||||
renderSectionPicker();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLineInFile(lineNum, newText) {
|
||||
console.log('updateLineInFile:', lineNum, newText);
|
||||
const file = await state.fileHandle.getFile();
|
||||
const text = await file.text();
|
||||
const lines = text.split('\n');
|
||||
lines[lineNum] = newText;
|
||||
await writeFile(lines.join('\n'));
|
||||
}
|
||||
|
||||
async function moveLineInFile(fromLine, toLine) {
|
||||
console.log('moveLineInFile: from line', fromLine, 'to line', toLine);
|
||||
const file = await state.fileHandle.getFile();
|
||||
const text = await file.text();
|
||||
const lines = text.split('\n');
|
||||
|
||||
console.log('Before move, line', fromLine, '=', lines[fromLine]);
|
||||
console.log('Before move, line', toLine, '=', lines[toLine]);
|
||||
|
||||
const [movedLine] = lines.splice(fromLine, 1);
|
||||
lines.splice(toLine, 0, movedLine);
|
||||
|
||||
console.log('After move, line', toLine, '=', lines[toLine]);
|
||||
|
||||
await writeFile(lines.join('\n'));
|
||||
}
|
||||
|
||||
async function loadFile() {
|
||||
try {
|
||||
const file = await state.fileHandle.getFile();
|
||||
state.lastModified = file.lastModified;
|
||||
const text = await file.text();
|
||||
state.sections = parseMarkdown(text);
|
||||
|
||||
console.log('Parsed sections:', state.sections);
|
||||
|
||||
if (state.filePath) {
|
||||
const key = `kbtd:${state.filePath}`;
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
try {
|
||||
const uiState = JSON.parse(stored);
|
||||
|
||||
if (state.currentSectionIndex === -1) {
|
||||
const idx = state.sections.findIndex(s => s.name === uiState.currentSection);
|
||||
if (idx !== -1) state.currentSectionIndex = idx;
|
||||
}
|
||||
|
||||
if (uiState.showCompleted !== undefined) {
|
||||
state.showCompleted = uiState.showCompleted;
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
}
|
||||
|
||||
if (state.currentSectionIndex !== -1 && state.sections[state.currentSectionIndex]) {
|
||||
renderBoard();
|
||||
} else {
|
||||
renderSectionPicker();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading file:', err);
|
||||
alert('Error loading file. See console.');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPermission(handle) {
|
||||
const opts = { mode: 'readwrite' };
|
||||
if ((await handle.queryPermission(opts)) === 'granted') {
|
||||
return true;
|
||||
}
|
||||
if ((await handle.requestPermission(opts)) === 'granted') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- UI Rendering ---
|
||||
|
|
@ -449,7 +529,6 @@
|
|||
colDiv.className = 'column';
|
||||
colDiv.dataset.colIndex = colIndex;
|
||||
|
||||
// Drag over/drop handlers for column
|
||||
colDiv.ondragover = handleDragOver;
|
||||
colDiv.ondrop = (e) => handleDrop(e, colIndex);
|
||||
|
||||
|
|
@ -471,11 +550,23 @@
|
|||
cardDiv.textContent = card.text;
|
||||
cardDiv.draggable = true;
|
||||
|
||||
// Click to toggle
|
||||
cardDiv.onclick = () => toggleCard(colIndex, cardIndex);
|
||||
cardDiv.dataset.lineNumber = card.lineNumber;
|
||||
cardDiv.dataset.originalLine = card.originalLine;
|
||||
cardDiv.dataset.colIndex = colIndex;
|
||||
cardDiv.dataset.cardIndex = cardIndex;
|
||||
|
||||
// Drag handlers
|
||||
cardDiv.ondragstart = (e) => handleDragStart(e, colIndex, cardIndex);
|
||||
cardDiv.onclick = async () => {
|
||||
const lineNum = parseInt(cardDiv.dataset.lineNumber);
|
||||
const originalLine = cardDiv.dataset.originalLine;
|
||||
|
||||
const newLine = originalLine.includes('- [x]')
|
||||
? originalLine.replace('- [x]', '- [ ]')
|
||||
: originalLine.replace('- [ ]', '- [x]');
|
||||
|
||||
await updateLineInFile(lineNum, newLine);
|
||||
};
|
||||
|
||||
cardDiv.ondragstart = (e) => handleDragStart(e, cardDiv);
|
||||
cardDiv.ondragend = handleDragEnd;
|
||||
|
||||
cardList.appendChild(cardDiv);
|
||||
|
|
@ -492,7 +583,6 @@
|
|||
|
||||
async function selectSection(index) {
|
||||
state.currentSectionIndex = index;
|
||||
// Persist selection
|
||||
if (state.filePath) {
|
||||
const key = `kbtd:${state.filePath}`;
|
||||
const uiState = {
|
||||
|
|
@ -504,14 +594,6 @@
|
|||
renderBoard();
|
||||
}
|
||||
|
||||
async function toggleCard(colIndex, cardIndex) {
|
||||
const section = state.sections[state.currentSectionIndex];
|
||||
const card = section.columns[colIndex].cards[cardIndex];
|
||||
card.completed = !card.completed;
|
||||
await saveFile();
|
||||
renderBoard();
|
||||
}
|
||||
|
||||
function toggleShowCompleted() {
|
||||
state.showCompleted = !state.showCompleted;
|
||||
|
||||
|
|
@ -533,13 +615,16 @@
|
|||
|
||||
// --- Drag and Drop ---
|
||||
|
||||
let dragSource = null; // { colIndex, cardIndex }
|
||||
let dragSource = null;
|
||||
|
||||
function handleDragStart(e, colIndex, cardIndex) {
|
||||
dragSource = { colIndex, cardIndex };
|
||||
function handleDragStart(e, cardDiv) {
|
||||
dragSource = {
|
||||
lineNumber: parseInt(cardDiv.dataset.lineNumber),
|
||||
colIndex: parseInt(cardDiv.dataset.colIndex),
|
||||
cardIndex: parseInt(cardDiv.dataset.cardIndex)
|
||||
};
|
||||
e.target.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Required for Firefox
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify(dragSource));
|
||||
}
|
||||
|
||||
|
|
@ -556,98 +641,41 @@
|
|||
|
||||
async function handleDrop(e, targetColIndex) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (!dragSource) return;
|
||||
|
||||
const { colIndex: sourceColIndex, cardIndex: sourceCardIndex } = dragSource;
|
||||
const sourceColIndex = dragSource.colIndex;
|
||||
const sourceLineNumber = dragSource.lineNumber;
|
||||
|
||||
// Don't do anything if dropped in same place (logic can be improved to reorder within column)
|
||||
// For now, just append to end of target column if different column, or reorder if same.
|
||||
console.log('handleDrop: sourceColIndex=', sourceColIndex, 'targetColIndex=', targetColIndex);
|
||||
|
||||
if (sourceColIndex === targetColIndex) {
|
||||
console.log('Same column, ignoring drop');
|
||||
return;
|
||||
}
|
||||
|
||||
const section = state.sections[state.currentSectionIndex];
|
||||
const sourceCol = section.columns[sourceColIndex];
|
||||
const targetCol = section.columns[targetColIndex];
|
||||
const targetColumn = section.columns[targetColIndex];
|
||||
|
||||
// Remove from source
|
||||
const [card] = sourceCol.cards.splice(sourceCardIndex, 1);
|
||||
console.log('Target column:', targetColumn.name, 'headerLineNumber=', targetColumn.headerLineNumber);
|
||||
|
||||
// If dropping on the column itself, append to end.
|
||||
// To implement reordering within list, we'd need to calculate index based on Y position.
|
||||
// For MVP/Spec "Drag and drop cards between columns", appending is sufficient.
|
||||
// But let's try to find insertion point if dropped on a card.
|
||||
|
||||
// Simple implementation: Append to target column
|
||||
targetCol.cards.push(card);
|
||||
|
||||
await saveFile();
|
||||
renderBoard();
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- File Operations ---
|
||||
|
||||
async function loadFile() {
|
||||
try {
|
||||
const file = await state.fileHandle.getFile();
|
||||
state.lastModified = file.lastModified;
|
||||
const text = await file.text();
|
||||
state.sections = parseMarkdown(text);
|
||||
|
||||
// Restore UI state
|
||||
if (state.filePath) {
|
||||
const key = `kbtd:${state.filePath}`;
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
try {
|
||||
const uiState = JSON.parse(stored);
|
||||
|
||||
if (state.currentSectionIndex === -1) {
|
||||
const idx = state.sections.findIndex(s => s.name === uiState.currentSection);
|
||||
if (idx !== -1) state.currentSectionIndex = idx;
|
||||
}
|
||||
|
||||
if (uiState.showCompleted !== undefined) {
|
||||
state.showCompleted = uiState.showCompleted;
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
let targetLineNumber;
|
||||
if (targetColumn.cards.length === 0) {
|
||||
targetLineNumber = targetColumn.headerLineNumber + 1;
|
||||
console.log('Empty column, inserting after header at line', targetLineNumber);
|
||||
} else {
|
||||
const lastCard = targetColumn.cards[targetColumn.cards.length - 1];
|
||||
targetLineNumber = lastCard.lineNumber + 1;
|
||||
if (lastCard.nestedLines.length > 0) {
|
||||
targetLineNumber += lastCard.nestedLines.length;
|
||||
}
|
||||
|
||||
if (state.currentSectionIndex !== -1 && state.sections[state.currentSectionIndex]) {
|
||||
renderBoard();
|
||||
} else {
|
||||
renderSectionPicker();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading file:', err);
|
||||
alert('Error loading file. See console.');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
try {
|
||||
const text = serializeToMarkdown(state.sections);
|
||||
const writable = await state.fileHandle.createWritable();
|
||||
await writable.write(text);
|
||||
await writable.close();
|
||||
|
||||
// Update lastModified to avoid reload loop
|
||||
const file = await state.fileHandle.getFile();
|
||||
state.lastModified = file.lastModified;
|
||||
} catch (err) {
|
||||
console.error('Error saving file:', err);
|
||||
alert('Error saving file.');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPermission(handle) {
|
||||
const opts = { mode: 'readwrite' };
|
||||
if ((await handle.queryPermission(opts)) === 'granted') {
|
||||
return true;
|
||||
}
|
||||
if ((await handle.requestPermission(opts)) === 'granted') {
|
||||
return true;
|
||||
console.log('Non-empty column, inserting after last card at line', targetLineNumber);
|
||||
}
|
||||
|
||||
await moveLineInFile(sourceLineNumber, targetLineNumber);
|
||||
|
||||
dragSource = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -666,7 +694,6 @@
|
|||
const handle = await getFileHandle(state.filePath);
|
||||
if (handle) {
|
||||
state.fileHandle = handle;
|
||||
// Check permission
|
||||
const perm = await handle.queryPermission({ mode: 'readwrite' });
|
||||
if (perm === 'granted') {
|
||||
await loadFile();
|
||||
|
|
@ -675,12 +702,6 @@
|
|||
showScreen('permission-screen');
|
||||
}
|
||||
} else {
|
||||
// No handle found for this path, show picker to associate
|
||||
// Actually spec says: "If no handle: Show file picker button"
|
||||
// But we are at a specific URL ?file=...
|
||||
// The user expects to open THAT file.
|
||||
// We can't open it automatically without a handle.
|
||||
// So we ask them to pick it.
|
||||
showScreen('landing-screen');
|
||||
document.getElementById('landing-error').textContent =
|
||||
`Please select the file: ${state.filePath}`;
|
||||
|
|
@ -721,21 +742,6 @@
|
|||
|
||||
state.fileHandle = handle;
|
||||
|
||||
// If we didn't have a filePath (landing page), we don't have one to store against yet.
|
||||
// But wait, the spec says: "Parse ?file= from URL query string".
|
||||
// If no file param, we are on landing page.
|
||||
// If we pick a file here, we should probably reload with the file param?
|
||||
// Or just proceed.
|
||||
// Spec says: "On file picker success: Store handle in IndexedDB -> Load and parse file".
|
||||
// But we need a key for IndexedDB.
|
||||
// If we are in "No file associated" state (landing page), we might not have a path.
|
||||
// However, the shell script always launches with ?file=.
|
||||
// If the user just opens the HTML file directly, they might not have ?file=.
|
||||
// In that case, we can't really persist the handle effectively for the "next time" unless we know the path.
|
||||
// But `showOpenFilePicker` returns a handle. We can get the name, but not the full path (security).
|
||||
// So if we are running without ?file=, we can just run in "anonymous" mode or similar.
|
||||
// BUT, the spec assumes `kbtd` launcher usage which provides `?file=`.
|
||||
|
||||
if (state.filePath) {
|
||||
await storeFileHandle(state.filePath, handle);
|
||||
}
|
||||
|
|
@ -770,16 +776,9 @@
|
|||
};
|
||||
|
||||
document.getElementById('change-file-btn').onclick = () => {
|
||||
// To change file, we basically want to pick a new one.
|
||||
// If we are in the app, we might want to clear state.
|
||||
// For now, just reload the page without params? Or show picker.
|
||||
// Spec doesn't explicitly define "Change File" behavior other than "Back to section picker".
|
||||
// But if I want to open a DIFFERENT file?
|
||||
// I'll just trigger the open file flow.
|
||||
document.getElementById('open-file-btn').click();
|
||||
};
|
||||
|
||||
// Start
|
||||
init();
|
||||
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue