kanban-lite 1.0.32 → 1.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - **Attachments subfolder**: attachments for the markdown storage engine are now stored in an `attachments/` subdirectory inside each column folder (e.g. `.kanban/boards/default/backlog/attachments/`) instead of alongside the card `.md` files
12
+ - **Browser-viewable attachments**: PDFs and other binary attachments now open with the OS/browser default viewer in the VS Code extension; the standalone server now serves PDF, JPEG, GIF, WebP, CSV, plain-text, and XML attachments with correct `Content-Type` headers so browsers render them inline in a new tab
13
+ - **KanbanSDK.getAttachmentDir(cardId, boardId?)**: new public SDK method that returns the absolute path to the attachment directory for a card (delegates to the active storage engine)
11
14
  - **Pluggable storage engine**: new `StorageEngine` interface (`src/sdk/storage/types.ts`) decouples all card I/O from the SDK business logic
12
15
  - **SQLite storage engine**: `SqliteStorageEngine` stores cards and comments in a single `.kanban/kanban.db` file using `better-sqlite3`; config (boards, columns, labels, webhooks) always stays in `.kanban.json`
13
16
  - **Markdown storage engine**: `MarkdownStorageEngine` wraps the existing file-based I/O, unchanged default behavior
package/dist/cli.js CHANGED
@@ -506,11 +506,14 @@ async function moveCardFile(currentPath, kanbanDir, newStatus, attachments) {
506
506
  await fs2.rename(currentPath, targetPath);
507
507
  if (attachments && attachments.length > 0) {
508
508
  const sourceDir = path2.dirname(currentPath);
509
+ const sourceAttachmentsDir = path2.join(sourceDir, "attachments");
510
+ const destAttachmentsDir = path2.join(targetDir, "attachments");
509
511
  for (const attachment of attachments) {
510
- const srcAttach = path2.join(sourceDir, attachment);
511
- const destAttach = path2.join(targetDir, attachment);
512
+ const srcAttach = path2.join(sourceAttachmentsDir, attachment);
513
+ const destAttach = path2.join(destAttachmentsDir, attachment);
512
514
  try {
513
515
  await fs2.access(srcAttach);
516
+ await fs2.mkdir(destAttachmentsDir, { recursive: true });
514
517
  await fs2.rename(srcAttach, destAttach);
515
518
  } catch {
516
519
  }
@@ -3516,15 +3519,16 @@ var init_markdown = __esm({
3516
3519
  }
3517
3520
  // --- Attachments ---
3518
3521
  getCardDir(card) {
3519
- return path5.dirname(card.filePath);
3522
+ return path5.join(path5.dirname(card.filePath), "attachments");
3520
3523
  }
3521
3524
  async copyAttachment(sourcePath, card) {
3522
3525
  const filename = path5.basename(sourcePath);
3523
- const cardDir = this.getCardDir(card);
3524
- const destPath = path5.join(cardDir, filename);
3525
- const sourceDir = path5.dirname(path5.resolve(sourcePath));
3526
- if (sourceDir !== cardDir) {
3527
- await fs4.copyFile(path5.resolve(sourcePath), destPath);
3526
+ const attachmentDir = this.getCardDir(card);
3527
+ await fs4.mkdir(attachmentDir, { recursive: true });
3528
+ const destPath = path5.join(attachmentDir, filename);
3529
+ const resolvedSource = path5.resolve(sourcePath);
3530
+ if (path5.dirname(resolvedSource) !== attachmentDir) {
3531
+ await fs4.copyFile(resolvedSource, destPath);
3528
3532
  }
3529
3533
  }
3530
3534
  // --- Private helpers ---
@@ -5916,6 +5920,28 @@ var init_KanbanSDK = __esm({
5916
5920
  throw new Error(`Card not found: ${cardId}`);
5917
5921
  return card.attachments;
5918
5922
  }
5923
+ /**
5924
+ * Returns the absolute path to the attachment directory for a card.
5925
+ *
5926
+ * For the markdown engine this is `{column_dir}/attachments/`.
5927
+ * For the SQLite engine this is `.kanban/boards/{boardId}/attachments/{cardId}/`.
5928
+ *
5929
+ * @param cardId - The ID of the card.
5930
+ * @param boardId - Optional board ID. Defaults to the workspace's default board.
5931
+ * @returns A promise resolving to the absolute directory path, or `null` if the card is not found.
5932
+ *
5933
+ * @example
5934
+ * ```ts
5935
+ * const dir = await sdk.getAttachmentDir('42')
5936
+ * // '/workspace/.kanban/boards/default/backlog/attachments'
5937
+ * ```
5938
+ */
5939
+ async getAttachmentDir(cardId, boardId) {
5940
+ const card = await this.getCard(cardId, boardId);
5941
+ if (!card)
5942
+ return null;
5943
+ return this._storage.getCardDir(card);
5944
+ }
5919
5945
  // --- Comment management ---
5920
5946
  /**
5921
5947
  * Lists all comments on a card.
@@ -51778,7 +51804,8 @@ function startServer(kanbanDir, port, webviewDir) {
51778
51804
  const card = cards.find((f) => f.id === cardId);
51779
51805
  if (!card)
51780
51806
  return false;
51781
- const cardDir = path10.dirname(card.filePath);
51807
+ const cardDir = sdk.storageEngine.getCardDir(card);
51808
+ fs7.mkdirSync(cardDir, { recursive: true });
51782
51809
  fs7.writeFileSync(path10.join(cardDir, filename), fileData);
51783
51810
  migrating = true;
51784
51811
  try {
@@ -52525,7 +52552,7 @@ function startServer(kanbanDir, port, webviewDir) {
52525
52552
  const card = cards.find((f) => f.id === id);
52526
52553
  if (!card)
52527
52554
  return jsonError(res, 404, "Task not found");
52528
- const cardDir = path10.dirname(card.filePath);
52555
+ const cardDir = sdk.storageEngine.getCardDir(card);
52529
52556
  const attachmentPath = path10.resolve(cardDir, attachName);
52530
52557
  if (!attachmentPath.startsWith(absoluteKanbanDir)) {
52531
52558
  res.writeHead(403, { "Content-Type": "text/plain" });
@@ -52844,7 +52871,7 @@ function startServer(kanbanDir, port, webviewDir) {
52844
52871
  res.end("Card not found");
52845
52872
  return;
52846
52873
  }
52847
- const cardDir = path10.dirname(card.filePath);
52874
+ const cardDir = sdk.storageEngine.getCardDir(card);
52848
52875
  const attachmentPath = path10.resolve(cardDir, filename);
52849
52876
  if (!attachmentPath.startsWith(absoluteKanbanDir)) {
52850
52877
  res.writeHead(403, { "Content-Type": "text/plain" });
@@ -52989,8 +53016,16 @@ var init_server3 = __esm({
52989
53016
  ".css": "text/css",
52990
53017
  ".json": "application/json",
52991
53018
  ".png": "image/png",
53019
+ ".jpg": "image/jpeg",
53020
+ ".jpeg": "image/jpeg",
53021
+ ".gif": "image/gif",
53022
+ ".webp": "image/webp",
52992
53023
  ".svg": "image/svg+xml",
52993
53024
  ".ico": "image/x-icon",
53025
+ ".pdf": "application/pdf",
53026
+ ".txt": "text/plain",
53027
+ ".xml": "text/xml",
53028
+ ".csv": "text/csv",
52994
53029
  ".map": "application/json"
52995
53030
  };
52996
53031
  }
package/dist/extension.js CHANGED
@@ -318,11 +318,14 @@ async function moveCardFile(currentPath, kanbanDir, newStatus, attachments) {
318
318
  await fs2.rename(currentPath, targetPath);
319
319
  if (attachments && attachments.length > 0) {
320
320
  const sourceDir = path2.dirname(currentPath);
321
+ const sourceAttachmentsDir = path2.join(sourceDir, "attachments");
322
+ const destAttachmentsDir = path2.join(targetDir, "attachments");
321
323
  for (const attachment of attachments) {
322
- const srcAttach = path2.join(sourceDir, attachment);
323
- const destAttach = path2.join(targetDir, attachment);
324
+ const srcAttach = path2.join(sourceAttachmentsDir, attachment);
325
+ const destAttach = path2.join(destAttachmentsDir, attachment);
324
326
  try {
325
327
  await fs2.access(srcAttach);
328
+ await fs2.mkdir(destAttachmentsDir, { recursive: true });
326
329
  await fs2.rename(srcAttach, destAttach);
327
330
  } catch {
328
331
  }
@@ -3328,15 +3331,16 @@ var init_markdown = __esm({
3328
3331
  }
3329
3332
  // --- Attachments ---
3330
3333
  getCardDir(card) {
3331
- return path5.dirname(card.filePath);
3334
+ return path5.join(path5.dirname(card.filePath), "attachments");
3332
3335
  }
3333
3336
  async copyAttachment(sourcePath, card) {
3334
3337
  const filename = path5.basename(sourcePath);
3335
- const cardDir = this.getCardDir(card);
3336
- const destPath = path5.join(cardDir, filename);
3337
- const sourceDir = path5.dirname(path5.resolve(sourcePath));
3338
- if (sourceDir !== cardDir) {
3339
- await fs4.copyFile(path5.resolve(sourcePath), destPath);
3338
+ const attachmentDir = this.getCardDir(card);
3339
+ await fs4.mkdir(attachmentDir, { recursive: true });
3340
+ const destPath = path5.join(attachmentDir, filename);
3341
+ const resolvedSource = path5.resolve(sourcePath);
3342
+ if (path5.dirname(resolvedSource) !== attachmentDir) {
3343
+ await fs4.copyFile(resolvedSource, destPath);
3340
3344
  }
3341
3345
  }
3342
3346
  // --- Private helpers ---
@@ -9640,6 +9644,28 @@ var KanbanSDK = class {
9640
9644
  throw new Error(`Card not found: ${cardId}`);
9641
9645
  return card.attachments;
9642
9646
  }
9647
+ /**
9648
+ * Returns the absolute path to the attachment directory for a card.
9649
+ *
9650
+ * For the markdown engine this is `{column_dir}/attachments/`.
9651
+ * For the SQLite engine this is `.kanban/boards/{boardId}/attachments/{cardId}/`.
9652
+ *
9653
+ * @param cardId - The ID of the card.
9654
+ * @param boardId - Optional board ID. Defaults to the workspace's default board.
9655
+ * @returns A promise resolving to the absolute directory path, or `null` if the card is not found.
9656
+ *
9657
+ * @example
9658
+ * ```ts
9659
+ * const dir = await sdk.getAttachmentDir('42')
9660
+ * // '/workspace/.kanban/boards/default/backlog/attachments'
9661
+ * ```
9662
+ */
9663
+ async getAttachmentDir(cardId, boardId) {
9664
+ const card = await this.getCard(cardId, boardId);
9665
+ if (!card)
9666
+ return null;
9667
+ return this._storage.getCardDir(card);
9668
+ }
9643
9669
  // --- Comment management ---
9644
9670
  /**
9645
9671
  * Lists all comments on a card.
@@ -11960,8 +11986,16 @@ var MIME_TYPES = {
11960
11986
  ".css": "text/css",
11961
11987
  ".json": "application/json",
11962
11988
  ".png": "image/png",
11989
+ ".jpg": "image/jpeg",
11990
+ ".jpeg": "image/jpeg",
11991
+ ".gif": "image/gif",
11992
+ ".webp": "image/webp",
11963
11993
  ".svg": "image/svg+xml",
11964
11994
  ".ico": "image/x-icon",
11995
+ ".pdf": "application/pdf",
11996
+ ".txt": "text/plain",
11997
+ ".xml": "text/xml",
11998
+ ".csv": "text/csv",
11965
11999
  ".map": "application/json"
11966
12000
  };
11967
12001
  function startServer(kanbanDir, port, webviewDir) {
@@ -12228,7 +12262,8 @@ function startServer(kanbanDir, port, webviewDir) {
12228
12262
  const card = cards.find((f) => f.id === cardId);
12229
12263
  if (!card)
12230
12264
  return false;
12231
- const cardDir = path9.dirname(card.filePath);
12265
+ const cardDir = sdk.storageEngine.getCardDir(card);
12266
+ fs6.mkdirSync(cardDir, { recursive: true });
12232
12267
  fs6.writeFileSync(path9.join(cardDir, filename), fileData);
12233
12268
  migrating = true;
12234
12269
  try {
@@ -12975,7 +13010,7 @@ function startServer(kanbanDir, port, webviewDir) {
12975
13010
  const card = cards.find((f) => f.id === id);
12976
13011
  if (!card)
12977
13012
  return jsonError(res, 404, "Task not found");
12978
- const cardDir = path9.dirname(card.filePath);
13013
+ const cardDir = sdk.storageEngine.getCardDir(card);
12979
13014
  const attachmentPath = path9.resolve(cardDir, attachName);
12980
13015
  if (!attachmentPath.startsWith(absoluteKanbanDir)) {
12981
13016
  res.writeHead(403, { "Content-Type": "text/plain" });
@@ -13294,7 +13329,7 @@ function startServer(kanbanDir, port, webviewDir) {
13294
13329
  res.end("Card not found");
13295
13330
  return;
13296
13331
  }
13297
- const cardDir = path9.dirname(card.filePath);
13332
+ const cardDir = sdk.storageEngine.getCardDir(card);
13298
13333
  const attachmentPath = path9.resolve(cardDir, filename);
13299
13334
  if (!attachmentPath.startsWith(absoluteKanbanDir)) {
13300
13335
  res.writeHead(403, { "Content-Type": "text/plain" });
@@ -14076,15 +14111,22 @@ var KanbanPanel = class _KanbanPanel {
14076
14111
  }
14077
14112
  }
14078
14113
  async _openAttachment(cardId, attachment) {
14114
+ const sdk = this._getSDK();
14079
14115
  const card = this._cards.find((f) => f.id === cardId);
14080
14116
  if (!card)
14081
14117
  return;
14082
- const featureDir = path10.dirname(card.filePath);
14083
- const attachmentPath = path10.resolve(featureDir, attachment);
14118
+ const attachmentDir = sdk ? await sdk.getAttachmentDir(cardId, this._currentBoardId) ?? path10.join(path10.dirname(card.filePath), "attachments") : path10.join(path10.dirname(card.filePath), "attachments");
14119
+ const attachmentPath = path10.resolve(attachmentDir, attachment);
14120
+ const ext = path10.extname(attachment).toLowerCase();
14121
+ const isTextFile = [".json", ".txt", ".md", ".html", ".xml", ".csv", ".ts", ".js", ".py", ".yaml", ".yml", ".toml", ".log"].includes(ext);
14084
14122
  try {
14085
14123
  await vscode.workspace.fs.stat(vscode.Uri.file(attachmentPath));
14086
- const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(attachmentPath));
14087
- await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.Beside });
14124
+ if (isTextFile) {
14125
+ const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(attachmentPath));
14126
+ await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.Beside });
14127
+ } else {
14128
+ await vscode.env.openExternal(vscode.Uri.file(attachmentPath));
14129
+ }
14088
14130
  } catch {
14089
14131
  await vscode.env.openExternal(vscode.Uri.file(attachmentPath));
14090
14132
  }
@@ -291,11 +291,14 @@ async function moveCardFile(currentPath, kanbanDir, newStatus, attachments) {
291
291
  await fs2.rename(currentPath, targetPath);
292
292
  if (attachments && attachments.length > 0) {
293
293
  const sourceDir = path2.dirname(currentPath);
294
+ const sourceAttachmentsDir = path2.join(sourceDir, "attachments");
295
+ const destAttachmentsDir = path2.join(targetDir, "attachments");
294
296
  for (const attachment of attachments) {
295
- const srcAttach = path2.join(sourceDir, attachment);
296
- const destAttach = path2.join(targetDir, attachment);
297
+ const srcAttach = path2.join(sourceAttachmentsDir, attachment);
298
+ const destAttach = path2.join(destAttachmentsDir, attachment);
297
299
  try {
298
300
  await fs2.access(srcAttach);
301
+ await fs2.mkdir(destAttachmentsDir, { recursive: true });
299
302
  await fs2.rename(srcAttach, destAttach);
300
303
  } catch {
301
304
  }
@@ -3301,15 +3304,16 @@ var init_markdown = __esm({
3301
3304
  }
3302
3305
  // --- Attachments ---
3303
3306
  getCardDir(card) {
3304
- return path5.dirname(card.filePath);
3307
+ return path5.join(path5.dirname(card.filePath), "attachments");
3305
3308
  }
3306
3309
  async copyAttachment(sourcePath, card) {
3307
3310
  const filename = path5.basename(sourcePath);
3308
- const cardDir = this.getCardDir(card);
3309
- const destPath = path5.join(cardDir, filename);
3310
- const sourceDir = path5.dirname(path5.resolve(sourcePath));
3311
- if (sourceDir !== cardDir) {
3312
- await fs4.copyFile(path5.resolve(sourcePath), destPath);
3311
+ const attachmentDir = this.getCardDir(card);
3312
+ await fs4.mkdir(attachmentDir, { recursive: true });
3313
+ const destPath = path5.join(attachmentDir, filename);
3314
+ const resolvedSource = path5.resolve(sourcePath);
3315
+ if (path5.dirname(resolvedSource) !== attachmentDir) {
3316
+ await fs4.copyFile(resolvedSource, destPath);
3313
3317
  }
3314
3318
  }
3315
3319
  // --- Private helpers ---
@@ -5899,6 +5903,28 @@ var KanbanSDK = class {
5899
5903
  throw new Error(`Card not found: ${cardId}`);
5900
5904
  return card.attachments;
5901
5905
  }
5906
+ /**
5907
+ * Returns the absolute path to the attachment directory for a card.
5908
+ *
5909
+ * For the markdown engine this is `{column_dir}/attachments/`.
5910
+ * For the SQLite engine this is `.kanban/boards/{boardId}/attachments/{cardId}/`.
5911
+ *
5912
+ * @param cardId - The ID of the card.
5913
+ * @param boardId - Optional board ID. Defaults to the workspace's default board.
5914
+ * @returns A promise resolving to the absolute directory path, or `null` if the card is not found.
5915
+ *
5916
+ * @example
5917
+ * ```ts
5918
+ * const dir = await sdk.getAttachmentDir('42')
5919
+ * // '/workspace/.kanban/boards/default/backlog/attachments'
5920
+ * ```
5921
+ */
5922
+ async getAttachmentDir(cardId, boardId) {
5923
+ const card = await this.getCard(cardId, boardId);
5924
+ if (!card)
5925
+ return null;
5926
+ return this._storage.getCardDir(card);
5927
+ }
5902
5928
  // --- Comment management ---
5903
5929
  /**
5904
5930
  * Lists all comments on a card.
@@ -295,11 +295,14 @@ async function moveCardFile(currentPath, kanbanDir, newStatus, attachments) {
295
295
  await fs2.rename(currentPath, targetPath);
296
296
  if (attachments && attachments.length > 0) {
297
297
  const sourceDir = path2.dirname(currentPath);
298
+ const sourceAttachmentsDir = path2.join(sourceDir, "attachments");
299
+ const destAttachmentsDir = path2.join(targetDir, "attachments");
298
300
  for (const attachment of attachments) {
299
- const srcAttach = path2.join(sourceDir, attachment);
300
- const destAttach = path2.join(targetDir, attachment);
301
+ const srcAttach = path2.join(sourceAttachmentsDir, attachment);
302
+ const destAttach = path2.join(destAttachmentsDir, attachment);
301
303
  try {
302
304
  await fs2.access(srcAttach);
305
+ await fs2.mkdir(destAttachmentsDir, { recursive: true });
303
306
  await fs2.rename(srcAttach, destAttach);
304
307
  } catch {
305
308
  }
@@ -3305,15 +3308,16 @@ var init_markdown = __esm({
3305
3308
  }
3306
3309
  // --- Attachments ---
3307
3310
  getCardDir(card) {
3308
- return path5.dirname(card.filePath);
3311
+ return path5.join(path5.dirname(card.filePath), "attachments");
3309
3312
  }
3310
3313
  async copyAttachment(sourcePath, card) {
3311
3314
  const filename = path5.basename(sourcePath);
3312
- const cardDir = this.getCardDir(card);
3313
- const destPath = path5.join(cardDir, filename);
3314
- const sourceDir = path5.dirname(path5.resolve(sourcePath));
3315
- if (sourceDir !== cardDir) {
3316
- await fs4.copyFile(path5.resolve(sourcePath), destPath);
3315
+ const attachmentDir = this.getCardDir(card);
3316
+ await fs4.mkdir(attachmentDir, { recursive: true });
3317
+ const destPath = path5.join(attachmentDir, filename);
3318
+ const resolvedSource = path5.resolve(sourcePath);
3319
+ if (path5.dirname(resolvedSource) !== attachmentDir) {
3320
+ await fs4.copyFile(resolvedSource, destPath);
3317
3321
  }
3318
3322
  }
3319
3323
  // --- Private helpers ---
@@ -5922,6 +5926,28 @@ var KanbanSDK = class {
5922
5926
  throw new Error(`Card not found: ${cardId}`);
5923
5927
  return card.attachments;
5924
5928
  }
5929
+ /**
5930
+ * Returns the absolute path to the attachment directory for a card.
5931
+ *
5932
+ * For the markdown engine this is `{column_dir}/attachments/`.
5933
+ * For the SQLite engine this is `.kanban/boards/{boardId}/attachments/{cardId}/`.
5934
+ *
5935
+ * @param cardId - The ID of the card.
5936
+ * @param boardId - Optional board ID. Defaults to the workspace's default board.
5937
+ * @returns A promise resolving to the absolute directory path, or `null` if the card is not found.
5938
+ *
5939
+ * @example
5940
+ * ```ts
5941
+ * const dir = await sdk.getAttachmentDir('42')
5942
+ * // '/workspace/.kanban/boards/default/backlog/attachments'
5943
+ * ```
5944
+ */
5945
+ async getAttachmentDir(cardId, boardId) {
5946
+ const card = await this.getCard(cardId, boardId);
5947
+ if (!card)
5948
+ return null;
5949
+ return this._storage.getCardDir(card);
5950
+ }
5925
5951
  // --- Comment management ---
5926
5952
  /**
5927
5953
  * Lists all comments on a card.
@@ -302,11 +302,14 @@ async function moveCardFile(currentPath, kanbanDir, newStatus, attachments) {
302
302
  await fs2.rename(currentPath, targetPath);
303
303
  if (attachments && attachments.length > 0) {
304
304
  const sourceDir = path2.dirname(currentPath);
305
+ const sourceAttachmentsDir = path2.join(sourceDir, "attachments");
306
+ const destAttachmentsDir = path2.join(targetDir, "attachments");
305
307
  for (const attachment of attachments) {
306
- const srcAttach = path2.join(sourceDir, attachment);
307
- const destAttach = path2.join(targetDir, attachment);
308
+ const srcAttach = path2.join(sourceAttachmentsDir, attachment);
309
+ const destAttach = path2.join(destAttachmentsDir, attachment);
308
310
  try {
309
311
  await fs2.access(srcAttach);
312
+ await fs2.mkdir(destAttachmentsDir, { recursive: true });
310
313
  await fs2.rename(srcAttach, destAttach);
311
314
  } catch {
312
315
  }
@@ -3307,15 +3310,16 @@ var init_markdown = __esm({
3307
3310
  }
3308
3311
  // --- Attachments ---
3309
3312
  getCardDir(card) {
3310
- return path5.dirname(card.filePath);
3313
+ return path5.join(path5.dirname(card.filePath), "attachments");
3311
3314
  }
3312
3315
  async copyAttachment(sourcePath, card) {
3313
3316
  const filename = path5.basename(sourcePath);
3314
- const cardDir = this.getCardDir(card);
3315
- const destPath = path5.join(cardDir, filename);
3316
- const sourceDir = path5.dirname(path5.resolve(sourcePath));
3317
- if (sourceDir !== cardDir) {
3318
- await fs4.copyFile(path5.resolve(sourcePath), destPath);
3317
+ const attachmentDir = this.getCardDir(card);
3318
+ await fs4.mkdir(attachmentDir, { recursive: true });
3319
+ const destPath = path5.join(attachmentDir, filename);
3320
+ const resolvedSource = path5.resolve(sourcePath);
3321
+ if (path5.dirname(resolvedSource) !== attachmentDir) {
3322
+ await fs4.copyFile(resolvedSource, destPath);
3319
3323
  }
3320
3324
  }
3321
3325
  // --- Private helpers ---
@@ -5898,6 +5902,28 @@ var KanbanSDK = class {
5898
5902
  throw new Error(`Card not found: ${cardId}`);
5899
5903
  return card.attachments;
5900
5904
  }
5905
+ /**
5906
+ * Returns the absolute path to the attachment directory for a card.
5907
+ *
5908
+ * For the markdown engine this is `{column_dir}/attachments/`.
5909
+ * For the SQLite engine this is `.kanban/boards/{boardId}/attachments/{cardId}/`.
5910
+ *
5911
+ * @param cardId - The ID of the card.
5912
+ * @param boardId - Optional board ID. Defaults to the workspace's default board.
5913
+ * @returns A promise resolving to the absolute directory path, or `null` if the card is not found.
5914
+ *
5915
+ * @example
5916
+ * ```ts
5917
+ * const dir = await sdk.getAttachmentDir('42')
5918
+ * // '/workspace/.kanban/boards/default/backlog/attachments'
5919
+ * ```
5920
+ */
5921
+ async getAttachmentDir(cardId, boardId) {
5922
+ const card = await this.getCard(cardId, boardId);
5923
+ if (!card)
5924
+ return null;
5925
+ return this._storage.getCardDir(card);
5926
+ }
5901
5927
  // --- Comment management ---
5902
5928
  /**
5903
5929
  * Lists all comments on a card.
@@ -619,6 +619,23 @@ export declare class KanbanSDK {
619
619
  * ```
620
620
  */
621
621
  listAttachments(cardId: string, boardId?: string): Promise<string[]>;
622
+ /**
623
+ * Returns the absolute path to the attachment directory for a card.
624
+ *
625
+ * For the markdown engine this is `{column_dir}/attachments/`.
626
+ * For the SQLite engine this is `.kanban/boards/{boardId}/attachments/{cardId}/`.
627
+ *
628
+ * @param cardId - The ID of the card.
629
+ * @param boardId - Optional board ID. Defaults to the workspace's default board.
630
+ * @returns A promise resolving to the absolute directory path, or `null` if the card is not found.
631
+ *
632
+ * @example
633
+ * ```ts
634
+ * const dir = await sdk.getAttachmentDir('42')
635
+ * // '/workspace/.kanban/boards/default/backlog/attachments'
636
+ * ```
637
+ */
638
+ getAttachmentDir(cardId: string, boardId?: string): Promise<string | null>;
622
639
  /**
623
640
  * Lists all comments on a card.
624
641
  *
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kanban-lite",
3
3
  "displayName": "Kanban Lite",
4
4
  "description": "A kanban board for your codebase. Cards stored as markdown.",
5
- "version": "1.0.32",
5
+ "version": "1.0.33",
6
6
  "publisher": "borgius",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -744,18 +744,31 @@ export class KanbanPanel {
744
744
  }
745
745
 
746
746
  private async _openAttachment(cardId: string, attachment: string): Promise<void> {
747
+ const sdk = this._getSDK()
747
748
  const card = this._cards.find(f => f.id === cardId)
748
749
  if (!card) return
749
750
 
750
- const featureDir = path.dirname(card.filePath)
751
- const attachmentPath = path.resolve(featureDir, attachment)
751
+ // Resolve attachment directory via SDK (handles both markdown and SQLite paths)
752
+ const attachmentDir = sdk
753
+ ? (await sdk.getAttachmentDir(cardId, this._currentBoardId) ?? path.join(path.dirname(card.filePath), 'attachments'))
754
+ : path.join(path.dirname(card.filePath), 'attachments')
755
+
756
+ const attachmentPath = path.resolve(attachmentDir, attachment)
757
+
758
+ const ext = path.extname(attachment).toLowerCase()
759
+ // Text-based files open as VS Code editor tabs; everything else opens externally
760
+ // (PDF, images, etc. open via the OS default viewer or browser)
761
+ const isTextFile = ['.json', '.txt', '.md', '.html', '.xml', '.csv', '.ts', '.js', '.py', '.yaml', '.yml', '.toml', '.log'].includes(ext)
752
762
 
753
763
  try {
754
764
  await vscode.workspace.fs.stat(vscode.Uri.file(attachmentPath))
755
- const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(attachmentPath))
756
- await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.Beside })
765
+ if (isTextFile) {
766
+ const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(attachmentPath))
767
+ await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.Beside })
768
+ } else {
769
+ await vscode.env.openExternal(vscode.Uri.file(attachmentPath))
770
+ }
757
771
  } catch {
758
- // For binary files or files that can't be opened as text, reveal in OS
759
772
  await vscode.env.openExternal(vscode.Uri.file(attachmentPath))
760
773
  }
761
774
  }
@@ -1155,6 +1155,28 @@ export class KanbanSDK {
1155
1155
  return card.attachments
1156
1156
  }
1157
1157
 
1158
+ /**
1159
+ * Returns the absolute path to the attachment directory for a card.
1160
+ *
1161
+ * For the markdown engine this is `{column_dir}/attachments/`.
1162
+ * For the SQLite engine this is `.kanban/boards/{boardId}/attachments/{cardId}/`.
1163
+ *
1164
+ * @param cardId - The ID of the card.
1165
+ * @param boardId - Optional board ID. Defaults to the workspace's default board.
1166
+ * @returns A promise resolving to the absolute directory path, or `null` if the card is not found.
1167
+ *
1168
+ * @example
1169
+ * ```ts
1170
+ * const dir = await sdk.getAttachmentDir('42')
1171
+ * // '/workspace/.kanban/boards/default/backlog/attachments'
1172
+ * ```
1173
+ */
1174
+ async getAttachmentDir(cardId: string, boardId?: string): Promise<string | null> {
1175
+ const card = await this.getCard(cardId, boardId)
1176
+ if (!card) return null
1177
+ return this._storage.getCardDir(card)
1178
+ }
1179
+
1158
1180
  // --- Comment management ---
1159
1181
 
1160
1182
  /**
@@ -470,8 +470,8 @@ describe('KanbanSDK', () => {
470
470
  const updated = await sdk.addAttachment('card', srcFile)
471
471
  expect(updated.attachments).toContain('test-attach.txt')
472
472
 
473
- // Verify file was copied to the status subfolder
474
- const destPath = path.join(tempDir, 'boards', 'default', 'backlog', 'test-attach.txt')
473
+ // Verify file was copied to the attachments subfolder inside the status dir
474
+ const destPath = path.join(tempDir, 'boards', 'default', 'backlog', 'attachments', 'test-attach.txt')
475
475
  expect(fs.existsSync(destPath)).toBe(true)
476
476
 
477
477
  fs.unlinkSync(srcFile)
@@ -167,7 +167,7 @@ describe('MarkdownStorageEngine', () => {
167
167
  const card = makeCard({
168
168
  filePath: path.join(boardDir, 'backlog', '1-test-card.md'),
169
169
  })
170
- expect(engine.getCardDir(card)).toBe(path.join(boardDir, 'backlog'))
170
+ expect(engine.getCardDir(card)).toBe(path.join(boardDir, 'backlog', 'attachments'))
171
171
  })
172
172
  })
173
173
  })
@@ -72,11 +72,14 @@ export async function moveCardFile(
72
72
 
73
73
  if (attachments && attachments.length > 0) {
74
74
  const sourceDir = path.dirname(currentPath)
75
+ const sourceAttachmentsDir = path.join(sourceDir, 'attachments')
76
+ const destAttachmentsDir = path.join(targetDir, 'attachments')
75
77
  for (const attachment of attachments) {
76
- const srcAttach = path.join(sourceDir, attachment)
77
- const destAttach = path.join(targetDir, attachment)
78
+ const srcAttach = path.join(sourceAttachmentsDir, attachment)
79
+ const destAttach = path.join(destAttachmentsDir, attachment)
78
80
  try {
79
81
  await fs.access(srcAttach)
82
+ await fs.mkdir(destAttachmentsDir, { recursive: true })
80
83
  await fs.rename(srcAttach, destAttach)
81
84
  } catch {
82
85
  // Best effort -- skip failed attachment moves
@@ -161,16 +161,17 @@ export class MarkdownStorageEngine implements StorageEngine {
161
161
  // --- Attachments ---
162
162
 
163
163
  getCardDir(card: Card): string {
164
- return path.dirname(card.filePath)
164
+ return path.join(path.dirname(card.filePath), 'attachments')
165
165
  }
166
166
 
167
167
  async copyAttachment(sourcePath: string, card: Card): Promise<void> {
168
168
  const filename = path.basename(sourcePath)
169
- const cardDir = this.getCardDir(card)
170
- const destPath = path.join(cardDir, filename)
171
- const sourceDir = path.dirname(path.resolve(sourcePath))
172
- if (sourceDir !== cardDir) {
173
- await fs.copyFile(path.resolve(sourcePath), destPath)
169
+ const attachmentDir = this.getCardDir(card)
170
+ await fs.mkdir(attachmentDir, { recursive: true })
171
+ const destPath = path.join(attachmentDir, filename)
172
+ const resolvedSource = path.resolve(sourcePath)
173
+ if (path.dirname(resolvedSource) !== attachmentDir) {
174
+ await fs.copyFile(resolvedSource, destPath)
174
175
  }
175
176
  }
176
177
 
@@ -28,8 +28,16 @@ const MIME_TYPES: Record<string, string> = {
28
28
  '.css': 'text/css',
29
29
  '.json': 'application/json',
30
30
  '.png': 'image/png',
31
+ '.jpg': 'image/jpeg',
32
+ '.jpeg': 'image/jpeg',
33
+ '.gif': 'image/gif',
34
+ '.webp': 'image/webp',
31
35
  '.svg': 'image/svg+xml',
32
36
  '.ico': 'image/x-icon',
37
+ '.pdf': 'application/pdf',
38
+ '.txt': 'text/plain',
39
+ '.xml': 'text/xml',
40
+ '.csv': 'text/csv',
33
41
  '.map': 'application/json'
34
42
  }
35
43
 
@@ -334,8 +342,9 @@ export function startServer(kanbanDir: string, port: number, webviewDir?: string
334
342
  const card = cards.find(f => f.id === cardId)
335
343
  if (!card) return false
336
344
 
337
- // Write file data to the card's directory
338
- const cardDir = path.dirname(card.filePath)
345
+ // Write file data to the card's attachment directory
346
+ const cardDir = sdk.storageEngine.getCardDir(card)
347
+ fs.mkdirSync(cardDir, { recursive: true })
339
348
  fs.writeFileSync(path.join(cardDir, filename), fileData)
340
349
 
341
350
  // Register attachment via SDK (skips copy since file is already in place)
@@ -1100,7 +1109,7 @@ export function startServer(kanbanDir: string, port: number, webviewDir?: string
1100
1109
  const { id, filename: attachName } = params
1101
1110
  const card = cards.find(f => f.id === id)
1102
1111
  if (!card) return jsonError(res, 404, 'Task not found')
1103
- const cardDir = path.dirname(card.filePath)
1112
+ const cardDir = sdk.storageEngine.getCardDir(card)
1104
1113
  const attachmentPath = path.resolve(cardDir, attachName)
1105
1114
  if (!attachmentPath.startsWith(absoluteKanbanDir)) {
1106
1115
  res.writeHead(403, { 'Content-Type': 'text/plain' })
@@ -1426,7 +1435,7 @@ export function startServer(kanbanDir: string, port: number, webviewDir?: string
1426
1435
  }
1427
1436
  const card = cards.find(f => f.id === cardId)
1428
1437
  if (!card) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Card not found'); return }
1429
- const cardDir = path.dirname(card.filePath)
1438
+ const cardDir = sdk.storageEngine.getCardDir(card)
1430
1439
  const attachmentPath = path.resolve(cardDir, filename)
1431
1440
  if (!attachmentPath.startsWith(absoluteKanbanDir)) {
1432
1441
  res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Forbidden'); return