Okoge-keys commited on
Commit
97a6924
·
verified ·
1 Parent(s): 2c72ed8

Delete 軽量Kanban_app_v5/app.js

Browse files
Files changed (1) hide show
  1. 軽量Kanban_app_v5/app.js +0 -1413
軽量Kanban_app_v5/app.js DELETED
@@ -1,1413 +0,0 @@
1
- 
2
- (() => {
3
- "use strict";
4
-
5
- const STORAGE_KEY = "toyota-kanban-tasks-v1";
6
- const SETTINGS_KEY = "toyota-kanban-settings-v1";
7
- const DEFAULT_TITLE = "トヨタ式カンバンボード";
8
- const PANEL_KEYS = ["analytics", "filters", "form"];
9
-
10
- const columnSettings = {
11
- backlog: { wip: Number.POSITIVE_INFINITY, label: "バックログ" },
12
- ready: { wip: 5, label: "準備中" },
13
- inProgress: { wip: 3, label: "仕掛中" },
14
- waiting: { wip: 2, label: "停止中" },
15
- done: { wip: Number.POSITIVE_INFINITY, label: "完了" }
16
- };
17
-
18
- const form = document.getElementById("task-form");
19
- const fields = {
20
- title: document.getElementById("task-title"),
21
- owner: document.getElementById("task-owner"),
22
- due: document.getElementById("task-due"),
23
- status: document.getElementById("task-status"),
24
- notes: document.getElementById("task-notes"),
25
- tags: document.getElementById("task-tags"),
26
- link: document.getElementById("task-link")
27
- };
28
- const messageArea = document.getElementById("message-area");
29
- const seedButton = document.getElementById("seed-demo");
30
- const template = document.getElementById("task-template");
31
- const dropzones = Array.from(document.querySelectorAll("[data-dropzone]"));
32
- const counters = Array.from(document.querySelectorAll("[data-counter]"));
33
- const metricElements = {
34
- total: document.querySelector("[data-metric=\"total\"]"),
35
- active: document.querySelector("[data-metric=\"active\"]"),
36
- throughput7: document.querySelector("[data-metric=\"throughput7\"]"),
37
- leadtime: document.querySelector("[data-metric=\"leadtime\"]")
38
- };
39
- const editDialog = document.getElementById("edit-dialog");
40
- const editForm = document.getElementById("edit-form");
41
- const editFields = editForm
42
- ? {
43
- title: document.getElementById("edit-title"),
44
- owner: document.getElementById("edit-owner"),
45
- due: document.getElementById("edit-due"),
46
- notes: document.getElementById("edit-notes"),
47
- tags: document.getElementById("edit-tags"),
48
- link: document.getElementById("edit-link")
49
- }
50
- : null;
51
-
52
- const boardTitleHeading = document.getElementById("board-title-heading");
53
- const titleEditButton = document.getElementById("title-edit");
54
- const panelSections = Array.from(document.querySelectorAll("[data-panel]"));
55
- const panels = new Map(panelSections.map((section) => [section.dataset.panel, section]));
56
- const panelToggleButtons = Array.from(document.querySelectorAll("[data-action=\"toggle-panel\"]"));
57
-
58
- const filterElements = {
59
- keyword: document.getElementById("filter-keyword"),
60
- dueStart: document.getElementById("filter-due-start"),
61
- dueEnd: document.getElementById("filter-due-end"),
62
- clear: document.getElementById("filter-clear"),
63
- tagList: document.getElementById("filter-tag-list")
64
- };
65
- const selectionElements = {
66
- counter: document.getElementById("selection-counter"),
67
- hint: document.getElementById("selection-hint"),
68
- selectAll: document.getElementById("selection-selectall"),
69
- clear: document.getElementById("selection-clear"),
70
- delete: document.getElementById("selection-delete")
71
- };
72
- const uiElements = {
73
- viewToggle: document.getElementById("view-toggle")
74
- };
75
-
76
- const filterState = {
77
- keyword: "",
78
- dueStart: "",
79
- dueEnd: "",
80
- tags: new Set()
81
- };
82
-
83
- const selectionState = {
84
- ids: new Set(),
85
- lastVisibleIds: []
86
- };
87
-
88
- const uiState = {
89
- compact: false
90
- };
91
-
92
- let settings = createDefaultSettings();
93
- let tasks = [];
94
- let messageTimer = null;
95
- let draggingTaskId = null;
96
- let editingTaskId = null;
97
-
98
- init();
99
-
100
- function createDefaultSettings() {
101
- return {
102
- title: DEFAULT_TITLE,
103
- panels: PANEL_KEYS.reduce((acc, key) => {
104
- acc[key] = false;
105
- return acc;
106
- }, {})
107
- };
108
- }
109
-
110
- function init() {
111
- loadSettings();
112
- applyTitle();
113
- applyPanelStates({ initial: true });
114
- loadFromStorage();
115
- attachFormEvents();
116
- attachDropzoneEvents();
117
- attachSeedButton();
118
- attachEditForm();
119
- attachFilterHandlers();
120
- attachSelectionHandlers();
121
- attachViewHandlers();
122
- attachPanelHandlers();
123
- attachTitleEditor();
124
- form?.addEventListener("reset", () => hideMessage());
125
- updateViewToggleLabel();
126
- updateSelectionUI();
127
- render();
128
- }
129
-
130
- function loadSettings() {
131
- try {
132
- const raw = window.localStorage.getItem(SETTINGS_KEY);
133
- if (raw) {
134
- const parsed = JSON.parse(raw);
135
- if (parsed && typeof parsed === "object") {
136
- settings = {
137
- title: typeof parsed.title === "string" && parsed.title.trim() ? parsed.title.trim() : DEFAULT_TITLE,
138
- panels: { ...createDefaultSettings().panels, ...(parsed.panels || {}) }
139
- };
140
- return;
141
- }
142
- }
143
- } catch (error) {
144
- console.warn("Failed to load settings:", error);
145
- }
146
- settings = createDefaultSettings();
147
- }
148
-
149
- function saveSettings() {
150
- try {
151
- window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
152
- } catch (error) {
153
- console.warn("Failed to save settings:", error);
154
- }
155
- }
156
-
157
- function applyTitle() {
158
- if (boardTitleHeading) {
159
- boardTitleHeading.textContent = settings.title;
160
- }
161
- document.title = settings.title;
162
- }
163
-
164
- function attachTitleEditor() {
165
- titleEditButton?.addEventListener("click", handleTitleEdit);
166
- }
167
-
168
- function handleTitleEdit() {
169
- const current = settings.title || DEFAULT_TITLE;
170
- const next = window.prompt("ボードタイトルを入力してください。", current);
171
- if (next === null) return;
172
- const trimmed = next.trim();
173
- settings.title = trimmed || DEFAULT_TITLE;
174
- applyTitle();
175
- saveSettings();
176
- showMessage("タイトルを更新しました。", "info");
177
- }
178
-
179
- function attachPanelHandlers() {
180
- panelToggleButtons.forEach((button) => {
181
- button.addEventListener("click", () => {
182
- const key = button.dataset.target;
183
- const section = panels.get(key);
184
- if (!key || !section) return;
185
- const willCollapse = !section.classList.contains("panel-collapsed");
186
- setPanelCollapsed(key, willCollapse);
187
- });
188
- });
189
- }
190
-
191
- function applyPanelStates({ initial = false } = {}) {
192
- PANEL_KEYS.forEach((key) => {
193
- const collapsed = Boolean(settings.panels[key]);
194
- setPanelCollapsed(key, collapsed, { save: !initial });
195
- });
196
- }
197
- function setPanelCollapsed(panelKey, collapsed, { save = true } = {}) {
198
- const section = panels.get(panelKey);
199
- if (!section) return;
200
- section.classList.toggle("panel-collapsed", collapsed);
201
- const body = section.querySelector(`[data-panel-body="${panelKey}"]`);
202
- if (body) {
203
- body.hidden = collapsed;
204
- }
205
- updatePanelButton(panelKey, collapsed);
206
- settings.panels[panelKey] = collapsed;
207
- if (save) {
208
- saveSettings();
209
- }
210
- }
211
-
212
- function updatePanelButton(panelKey, collapsed) {
213
- const button = panelToggleButtons.find((item) => item.dataset.target === panelKey);
214
- if (!button) return;
215
- button.textContent = collapsed ? "展開する" : "折りたたむ";
216
- button.setAttribute("aria-expanded", collapsed ? "false" : "true");
217
- }
218
-
219
- function attachFormEvents() {
220
- form?.addEventListener("submit", handleSubmit);
221
- }
222
-
223
- function handleSubmit(event) {
224
- event.preventDefault();
225
- const rawTitle = fields.title.value.trim();
226
- const rawOwner = fields.owner.value.trim();
227
- const rawNotes = fields.notes.value.trim();
228
- const status = fields.status.value;
229
- const due = fields.due.value;
230
- const tags = parseTags(fields.tags?.value ?? "");
231
- const link = normalizeLink(fields.link?.value ?? "");
232
-
233
- if (!rawTitle) {
234
- showMessage("タイトルを入力してください。", "warn");
235
- fields.title.focus();
236
- return;
237
- }
238
-
239
- if (!canEnterColumn(status)) {
240
- const limit = columnSettings[status]?.wip;
241
- const label = columnSettings[status]?.label ?? status;
242
- showMessage(`「${label}」はWIP上限(${limit})を超えられません。`, "warn");
243
- return;
244
- }
245
-
246
- const now = new Date();
247
- const newTask = {
248
- id: createId(),
249
- title: rawTitle,
250
- owner: rawOwner,
251
- due,
252
- notes: rawNotes,
253
- status,
254
- link,
255
- createdAt: now.toISOString(),
256
- tags
257
- };
258
-
259
- if (status === "done") {
260
- newTask.completedAt = now.toISOString();
261
- }
262
-
263
- tasks.push(newTask);
264
- persist();
265
- render();
266
- form.reset();
267
- fields.title.focus();
268
- showMessage("タスクを登録しました。", "info");
269
- }
270
-
271
- function attachDropzoneEvents() {
272
- dropzones.forEach((zone) => {
273
- zone.addEventListener("dragover", handleDragOver);
274
- zone.addEventListener("dragleave", handleDragLeave);
275
- zone.addEventListener("drop", handleDrop);
276
- });
277
- }
278
-
279
- function attachSeedButton() {
280
- seedButton?.addEventListener("click", seedDemoData);
281
- }
282
-
283
- function attachEditForm() {
284
- if (!editForm || !editDialog || !editFields) return;
285
-
286
- editForm.addEventListener("submit", handleEditSubmit);
287
- const cancelButton = editForm.querySelector("[data-action=\"cancel-edit\"]");
288
- cancelButton?.addEventListener("click", () => closeEditDialog());
289
-
290
- editDialog.addEventListener("cancel", () => closeEditDialog(true));
291
- editDialog.addEventListener("close", () => resetEditingState());
292
- }
293
-
294
- function attachFilterHandlers() {
295
- filterElements.keyword?.addEventListener("input", handleKeywordFilter);
296
- filterElements.dueStart?.addEventListener("change", handleDueFilter);
297
- filterElements.dueEnd?.addEventListener("change", handleDueFilter);
298
- filterElements.clear?.addEventListener("click", clearFilters);
299
- filterElements.tagList?.addEventListener("click", handleTagFilterClick);
300
- }
301
-
302
- function attachSelectionHandlers() {
303
- selectionElements.selectAll?.addEventListener("click", selectAllVisible);
304
- selectionElements.clear?.addEventListener("click", () => {
305
- clearSelection();
306
- syncSelectionCheckboxes();
307
- });
308
- selectionElements.delete?.addEventListener("click", deleteSelectedTasks);
309
- }
310
-
311
- function attachViewHandlers() {
312
- uiElements.viewToggle?.addEventListener("click", toggleViewMode);
313
- }
314
-
315
- function handleKeywordFilter(event) {
316
- filterState.keyword = event.target.value.trim();
317
- render();
318
- }
319
-
320
- function handleDueFilter() {
321
- filterState.dueStart = filterElements.dueStart?.value ?? "";
322
- filterState.dueEnd = filterElements.dueEnd?.value ?? "";
323
- render();
324
- }
325
-
326
- function handleTagFilterClick(event) {
327
- const button = event.target.closest("[data-tag-value]");
328
- if (!button) return;
329
- const tag = button.dataset.tagValue;
330
- if (!tag) return;
331
-
332
- if (filterState.tags.has(tag)) {
333
- filterState.tags.delete(tag);
334
- } else {
335
- filterState.tags.add(tag);
336
- }
337
- render();
338
- }
339
-
340
- function clearFilters() {
341
- filterState.keyword = "";
342
- filterState.dueStart = "";
343
- filterState.dueEnd = "";
344
- filterState.tags.clear();
345
- if (filterElements.keyword) {
346
- filterElements.keyword.value = "";
347
- }
348
- if (filterElements.dueStart) {
349
- filterElements.dueStart.value = "";
350
- }
351
- if (filterElements.dueEnd) {
352
- filterElements.dueEnd.value = "";
353
- }
354
- render();
355
- }
356
-
357
- function handleDragStart(event) {
358
- const card = event.currentTarget;
359
- const taskId = card.dataset.taskId;
360
- if (!taskId) return;
361
- draggingTaskId = taskId;
362
- event.dataTransfer.effectAllowed = "move";
363
- event.dataTransfer.setData("text/plain", taskId);
364
- card.classList.add("opacity-40");
365
- }
366
-
367
- function handleDragEnd(event) {
368
- event.currentTarget.classList.remove("opacity-40");
369
- draggingTaskId = null;
370
- dropzones.forEach((zone) => setDropzoneHighlight(zone, "none"));
371
- }
372
-
373
- function handleDragOver(event) {
374
- event.preventDefault();
375
- const zone = event.currentTarget;
376
- const targetColumn = zone.dataset.dropzone;
377
- const allowed = canEnterColumn(targetColumn, draggingTaskId);
378
- event.dataTransfer.dropEffect = allowed ? "move" : "none";
379
- setDropzoneHighlight(zone, allowed ? "allowed" : "denied");
380
- }
381
-
382
- function handleDragLeave(event) {
383
- setDropzoneHighlight(event.currentTarget, "none");
384
- }
385
-
386
- function handleDrop(event) {
387
- event.preventDefault();
388
- const zone = event.currentTarget;
389
- const targetColumn = zone.dataset.dropzone;
390
- const taskId = draggingTaskId || event.dataTransfer.getData("text/plain");
391
-
392
- setDropzoneHighlight(zone, "none");
393
-
394
- if (!taskId) return;
395
-
396
- if (!canEnterColumn(targetColumn, taskId)) {
397
- const limit = columnSettings[targetColumn]?.wip;
398
- const label = columnSettings[targetColumn]?.label ?? targetColumn;
399
- showMessage(`「${label}」はWIP上限(${limit})を超えられません。`, "warn");
400
- return;
401
- }
402
-
403
- moveTask(taskId, targetColumn);
404
- }
405
-
406
- function moveTask(taskId, targetColumn) {
407
- const task = tasks.find((item) => item.id === taskId);
408
- if (!task || task.status === targetColumn) return;
409
-
410
- task.status = targetColumn;
411
- if (targetColumn === "done") {
412
- task.completedAt = new Date().toISOString();
413
- }
414
-
415
- persist();
416
- render();
417
- showMessage(`タスクを「${columnSettings[targetColumn]?.label ?? targetColumn}」へ移動しました。`, "info");
418
- }
419
-
420
- function render() {
421
- cleanupSelection();
422
- renderTagFilters();
423
-
424
- const filterActive = isFilterActive();
425
- const filteredTasks = getFilteredTasks();
426
- const tasksToRender = filterActive ? filteredTasks : tasks;
427
-
428
- selectionState.lastVisibleIds = tasksToRender.map((task) => task.id);
429
-
430
- dropzones.forEach((zone) => zone.replaceChildren());
431
-
432
- const byColumn = new Map();
433
- tasksToRender
434
- .slice()
435
- .sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime())
436
- .forEach((task) => {
437
- const list = byColumn.get(task.status) ?? [];
438
- list.push(task);
439
- byColumn.set(task.status, list);
440
- });
441
-
442
- dropzones.forEach((zone) => {
443
- const columnKey = zone.dataset.dropzone;
444
- const columnTasks = byColumn.get(columnKey) ?? [];
445
- columnTasks.forEach((task) => zone.appendChild(buildTaskCard(task)));
446
- if (!zone.childElementCount) {
447
- zone.appendChild(createEmptyState(filterActive));
448
- }
449
- });
450
-
451
- updateCounters(filterActive, filteredTasks);
452
- renderMetrics(filteredTasks, filterActive);
453
- syncSelectionCheckboxes();
454
- updateSelectionUI();
455
- }
456
- function buildTaskCard(task) {
457
- const fragment = template.content.cloneNode(true);
458
- const card = fragment.querySelector("[data-task-id]");
459
- card.dataset.taskId = task.id;
460
-
461
- const isSelected = selectionState.ids.has(task.id);
462
- card.classList.toggle("kanban-card-selected", isSelected);
463
-
464
-
465
-
466
- const titleEl = card.querySelector("[data-field=\"title\"]");
467
- const ownerEl = card.querySelector("[data-field=\"owner\"]");
468
- const dueEl = card.querySelector("[data-field=\"due\"]");
469
- const notesEl = card.querySelector("[data-field=\"notes\"]");
470
- const tagsEl = card.querySelector("[data-field=\"tags\"]");
471
- const tagsSection = card.querySelector("[data-section=\"tags\"]");
472
- const linkSection = card.querySelector("[data-section=\"link\"]");
473
- const linkEl = card.querySelector("[data-field=\"link\"]");
474
- const doneButton = card.querySelector("[data-action=\"mark-done\"]");
475
- const editButton = card.querySelector("[data-action=\"edit-task\"]");
476
- const deleteButton = card.querySelector("[data-action=\"delete-task\"]");
477
- const checkbox = card.querySelector("[data-action=\"select-task\"]");
478
- const toggleDetailsButton = card.querySelector("[data-action=\"toggle-details\"]");
479
-
480
- titleEl.textContent = task.title;
481
- ownerEl.textContent = task.owner || "未設定";
482
- dueEl.textContent = formatDue(task.due);
483
- notesEl.textContent = task.notes || "メモなし";
484
- notesEl.classList.toggle("text-slate-500", !task.notes);
485
-
486
- if (tagsEl) {
487
- tagsEl.replaceChildren();
488
- const hasTags = Array.isArray(task.tags) && task.tags.length > 0;
489
- tagsSection?.classList.toggle("hidden", !hasTags);
490
- tagsSection?.setAttribute("aria-hidden", hasTags ? "false" : "true");
491
- tagsSection?.setAttribute("data-has-tags", hasTags ? "true" : "false");
492
- if (hasTags) {
493
- task.tags.forEach((tag) => {
494
- const pill = document.createElement("span");
495
- pill.textContent = `#${tag}`;
496
- pill.className = buildTagPillClass(filterState.tags.has(tag));
497
- tagsEl.appendChild(pill);
498
- });
499
- }
500
- }
501
-
502
- if (linkSection && linkEl) {
503
- if (task.link) {
504
- linkSection.classList.remove("hidden");
505
- linkEl.href = task.link;
506
- linkEl.textContent = formatLinkLabel(task.link);
507
- linkEl.title = task.link;
508
- } else {
509
- linkSection.classList.add("hidden");
510
- linkEl.removeAttribute("href");
511
- linkEl.removeAttribute("title");
512
- linkEl.textContent = "";
513
- }
514
- }
515
-
516
- if (checkbox) {
517
- checkbox.checked = isSelected;
518
- checkbox.addEventListener("click", (event) => event.stopPropagation());
519
- checkbox.addEventListener("mousedown", (event) => event.stopPropagation());
520
- checkbox.addEventListener("change", (event) => toggleSelection(task.id, event.target.checked));
521
- }
522
-
523
- if (doneButton) {
524
- const isDone = task.status === "done";
525
- doneButton.classList.toggle("hidden", isDone);
526
- doneButton.addEventListener("click", (event) => {
527
- event.preventDefault();
528
- event.stopPropagation();
529
- moveTask(task.id, "done");
530
- });
531
- }
532
-
533
- editButton?.addEventListener("click", (event) => {
534
- event.preventDefault();
535
- event.stopPropagation();
536
- openEditDialog(task.id);
537
- });
538
- deleteButton?.addEventListener("click", (event) => {
539
- event.preventDefault();
540
- event.stopPropagation();
541
- deleteTask(task.id);
542
- });
543
-
544
- if (toggleDetailsButton) {
545
- toggleDetailsButton.addEventListener("click", (event) => {
546
- event.preventDefault();
547
- event.stopPropagation();
548
- card.classList.toggle("kanban-card-expanded");
549
- updateCardDetailVisibility(card);
550
- });
551
- }
552
-
553
- card.addEventListener("dragstart", handleDragStart);
554
- card.addEventListener("dragend", handleDragEnd);
555
-
556
- applyCardLayout(card);
557
-
558
- return card;
559
- }
560
-
561
- function applyCardLayout(card) {
562
- if (!card) return;
563
- card.classList.toggle("kanban-card-compact", uiState.compact);
564
- if (!uiState.compact) {
565
- card.classList.remove("kanban-card-expanded");
566
- }
567
- updateCardDetailVisibility(card);
568
- }
569
-
570
- function updateCardDetailVisibility(card) {
571
- const isExpanded = card.classList.contains("kanban-card-expanded");
572
- const showDetails = !uiState.compact || isExpanded;
573
-
574
- const meta = card.querySelector("[data-section=\"meta\"]");
575
- const notes = card.querySelector("[data-section=\"notes\"]");
576
- const tags = card.querySelector("[data-section=\"tags\"]");
577
- const toggleDetailsButton = card.querySelector("[data-action=\"toggle-details\"]");
578
-
579
- if (meta) {
580
- meta.classList.toggle("hidden", !showDetails);
581
- }
582
- if (notes) {
583
- notes.classList.toggle("hidden", !showDetails);
584
- notes.classList.toggle("kanban-note-clamped", uiState.compact && !isExpanded);
585
- }
586
-
587
- if (tags) {
588
- const hasTags = tags && tags.getAttribute("data-has-tags") === "true";
589
- if (hasTags) {
590
- tags.classList.toggle("hidden", !showDetails);
591
- }
592
- }
593
- if (toggleDetailsButton) {
594
- const hasDetails = Boolean((meta && meta.textContent.trim()) || (notes && notes.textContent.trim() && notes.textContent.trim() !== "メモなし"));
595
- const shouldShowToggle = uiState.compact && hasDetails;
596
- toggleDetailsButton.classList.toggle("invisible", !shouldShowToggle);
597
- toggleDetailsButton.classList.toggle("pointer-events-none", !shouldShowToggle);
598
- toggleDetailsButton.textContent = isExpanded ? "折りたたむ" : "詳細";
599
- toggleDetailsButton.setAttribute("aria-expanded", isExpanded ? "true" : "false");
600
- }
601
- }
602
-
603
- function createEmptyState(filterActive) {
604
- const empty = document.createElement("p");
605
- empty.className = "rounded-lg border border-dashed border-slate-600/60 px-3 py-6 text-center text-xs text-slate-500";
606
- empty.textContent = filterActive ? "該当するカードがありません" : "カードなし";
607
- empty.setAttribute("aria-hidden", "true");
608
- return empty;
609
- }
610
-
611
- function updateCounters(filterActive, filteredTasks) {
612
- counters.forEach((counter) => {
613
- const columnKey = counter.dataset.counter;
614
- const totalCount = tasks.filter((task) => task.status === columnKey).length;
615
- const visibleCount = filterActive
616
- ? filteredTasks.filter((task) => task.status === columnKey).length
617
- : totalCount;
618
-
619
- counter.textContent = filterActive ? `${visibleCount}/${totalCount}` : String(totalCount);
620
-
621
- const parentArticle = counter.closest("article");
622
- if (!parentArticle) return;
623
-
624
- const limit = columnSettings[columnKey]?.wip;
625
- const isLimited = Number.isFinite(limit);
626
- parentArticle.classList.remove("border", "border-kanban-caution");
627
- if (isLimited && totalCount >= limit) {
628
- parentArticle.classList.add("border", "border-kanban-caution");
629
- }
630
- });
631
- }
632
-
633
- function renderMetrics(filteredTasks, filterActive) {
634
- const activeStatuses = new Set(["ready", "inProgress", "waiting"]);
635
-
636
- const visibleTasks = filterActive ? filteredTasks : tasks;
637
- const totalTasks = tasks.length;
638
- const visibleTotal = visibleTasks.length;
639
-
640
- const totalDisplay = filterActive
641
- ? formatFilteredCount(visibleTotal, totalTasks)
642
- : formatCount(totalTasks);
643
-
644
- const totalActive = tasks.filter((task) => activeStatuses.has(task.status)).length;
645
- const visibleActive = visibleTasks.filter((task) => activeStatuses.has(task.status)).length;
646
- const activeDisplay = filterActive
647
- ? formatFilteredCount(visibleActive, totalActive)
648
- : formatCount(totalActive);
649
-
650
- const now = new Date();
651
- const sevenDaysAgo = new Date(now);
652
- sevenDaysAgo.setDate(now.getDate() - 7);
653
-
654
- const totalDone = tasks.filter((task) => task.status === "done");
655
- const visibleDone = visibleTasks.filter((task) => task.status === "done");
656
-
657
- const totalThroughput7 = totalDone.filter((task) => isCompletedSince(task, sevenDaysAgo)).length;
658
- const visibleThroughput7 = visibleDone.filter((task) => isCompletedSince(task, sevenDaysAgo)).length;
659
- const throughputDisplay = filterActive
660
- ? formatFilteredCount(visibleThroughput7, totalThroughput7)
661
- : formatCount(totalThroughput7);
662
-
663
- const totalLeadAvg = computeAverageLead(totalDone);
664
- const visibleLeadAvg = computeAverageLead(visibleDone);
665
- const leadDisplay = filterActive
666
- ? formatLeadFiltered(visibleLeadAvg, totalLeadAvg)
667
- : formatLeadFiltered(totalLeadAvg, null);
668
-
669
- setMetric("total", totalDisplay);
670
- setMetric("active", activeDisplay);
671
- setMetric("throughput7", throughputDisplay);
672
- setMetric("leadtime", leadDisplay);
673
- }
674
-
675
- function computeAverageLead(doneTasks) {
676
- const leadTimes = doneTasks
677
- .map((task) => computeLeadTimeDays(task))
678
- .filter((value) => Number.isFinite(value));
679
- if (!leadTimes.length) return null;
680
- const total = leadTimes.reduce((sum, value) => sum + value, 0);
681
- return total / leadTimes.length;
682
- }
683
-
684
- function formatLeadFiltered(primary, overall) {
685
- const base = Number.isFinite(primary) ? `${primary.toFixed(1)}日` : "--";
686
- if (overall === null || overall === undefined) {
687
- return base;
688
- }
689
- const overallText = Number.isFinite(overall) ? `${overall.toFixed(1)}日` : "--";
690
- return `${base} / 全${overallText}`;
691
- }
692
-
693
- function setMetric(key, text) {
694
- const target = metricElements[key];
695
- if (!target) return;
696
- target.textContent = text;
697
- }
698
-
699
- function formatCount(count) {
700
- return `${count}枚`;
701
- }
702
-
703
- function formatFilteredCount(visible, total) {
704
- return `${formatCount(visible)} / 全${formatCount(total)}`;
705
- }
706
-
707
- function isCompletedSince(task, threshold) {
708
- if (!task.completedAt) return false;
709
- const completed = new Date(task.completedAt);
710
- return !Number.isNaN(completed.getTime()) && completed >= threshold;
711
- }
712
-
713
- function computeLeadTimeDays(task) {
714
- if (!task?.createdAt || !task?.completedAt) return Number.NaN;
715
- const start = new Date(task.createdAt);
716
- const end = new Date(task.completedAt);
717
- if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return Number.NaN;
718
- const diffMs = end.getTime() - start.getTime();
719
- if (diffMs < 0) return Number.NaN;
720
- return diffMs / (1000 * 60 * 60 * 24);
721
- }
722
-
723
- function setDropzoneHighlight(zone, state) {
724
- zone.classList.remove("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-accent", "ring-kanban-caution", "bg-slate-800/40", "bg-slate-900/40");
725
- if (state === "allowed") {
726
- zone.classList.add("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-accent", "bg-slate-800/40");
727
- } else if (state === "denied") {
728
- zone.classList.add("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-caution", "bg-slate-900/40");
729
- }
730
- }
731
-
732
- function canEnterColumn(columnKey, movingTaskId) {
733
- const settingsColumn = columnSettings[columnKey];
734
- if (!settingsColumn) return true;
735
- if (!Number.isFinite(settingsColumn.wip)) return true;
736
- const current = tasks.filter((task) => task.status === columnKey && task.id !== movingTaskId).length;
737
- return current < settingsColumn.wip;
738
- }
739
-
740
- function isFilterActive() {
741
- return (
742
- Boolean(filterState.keyword) ||
743
- Boolean(filterState.dueStart) ||
744
- Boolean(filterState.dueEnd) ||
745
- filterState.tags.size > 0
746
- );
747
- }
748
-
749
- function getFilteredTasks() {
750
- if (!isFilterActive()) return tasks;
751
- const keyword = filterState.keyword.toLowerCase();
752
- const startDate = parseISODate(filterState.dueStart);
753
- const endDate = parseISODate(filterState.dueEnd);
754
-
755
- return tasks.filter((task) => {
756
- const keywordMatch = keyword
757
- ? [task.title, task.owner, task.notes, task.due]
758
- .filter(Boolean)
759
- .some((value) => String(value).toLowerCase().includes(keyword))
760
- : true;
761
-
762
- const tagsMatch = filterState.tags.size
763
- ? [...filterState.tags].every((tag) => Array.isArray(task.tags) && task.tags.includes(tag))
764
- : true;
765
-
766
- const dueMatch = (() => {
767
- if (!startDate && !endDate) return true;
768
- if (!task.due) return false;
769
- const dueDate = parseISODate(task.due);
770
- if (!dueDate) return false;
771
- if (startDate && dueDate < startDate) return false;
772
- if (endDate && dueDate > endDate) return false;
773
- return true;
774
- })();
775
-
776
- return keywordMatch && tagsMatch && dueMatch;
777
- });
778
- }
779
-
780
- function renderTagFilters() {
781
- if (!filterElements.tagList) return;
782
-
783
- const keyword = filterState.keyword.trim().toLowerCase();
784
- const startDate = parseISODate(filterState.dueStart);
785
- const endDate = parseISODate(filterState.dueEnd);
786
-
787
- const sourceTasks = tasks.filter((task) => {
788
- const keywordMatch = keyword
789
- ? [task.title, task.owner, task.notes, task.due]
790
- .filter(Boolean)
791
- .some((value) => String(value).toLowerCase().includes(keyword))
792
- : true;
793
-
794
- const dueMatch = (() => {
795
- if (!startDate && !endDate) return true;
796
- if (!task.due) return false;
797
- const dueDate = parseISODate(task.due);
798
- if (!dueDate) return false;
799
- if (startDate && dueDate < startDate) return false;
800
- if (endDate && dueDate > endDate) return false;
801
- return true;
802
- })();
803
-
804
- return keywordMatch && dueMatch;
805
- });
806
-
807
- const allTags = Array.from(
808
- new Set(
809
- sourceTasks
810
- .flatMap((task) => (Array.isArray(task.tags) ? task.tags : []))
811
- .map((tag) => tag.trim())
812
- .filter(Boolean)
813
- )
814
- ).sort((a, b) => a.localeCompare(b, 'ja'));
815
-
816
- const validTags = new Set(allTags);
817
- let removed = false;
818
- [...filterState.tags].forEach((tag) => {
819
- if (!validTags.has(tag)) {
820
- filterState.tags.delete(tag);
821
- removed = true;
822
- }
823
- });
824
- if (removed && filterElements.keyword && !isFilterActive()) {
825
- filterElements.keyword.value = filterState.keyword;
826
- }
827
-
828
- filterElements.tagList.replaceChildren();
829
-
830
- if (!allTags.length) {
831
- const empty = document.createElement('p');
832
- empty.className = 'text-xs text-slate-500';
833
- empty.textContent = 'タグはまだ登録されていません。';
834
- filterElements.tagList.appendChild(empty);
835
- return;
836
- }
837
-
838
- allTags.forEach((tag) => {
839
- const button = document.createElement('button');
840
- button.type = 'button';
841
- button.dataset.tagValue = tag;
842
- button.textContent = `#${tag}`;
843
- button.className = buildTagFilterClass(filterState.tags.has(tag));
844
- filterElements.tagList.appendChild(button);
845
- });
846
- }
847
-
848
- function buildTagFilterClass(isActive) {
849
- const base = "rounded-full border px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring focus-visible:ring-kanban-accent focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900";
850
- return isActive
851
- ? `${base} border-kanban-accent bg-kanban-accent/20 text-kanban-accent`
852
- : `${base} border-slate-600/70 bg-slate-800/80 text-slate-300 hover:border-slate-400 hover:text-white`;
853
- }
854
-
855
- function buildTagPillClass(isActive) {
856
- const base = "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium";
857
- return isActive
858
- ? `${base} border-kanban-accent bg-kanban-accent/20 text-kanban-accent`
859
- : `${base} border-slate-600/70 bg-slate-800/80 text-slate-300`;
860
- }
861
-
862
- function cleanupSelection() {
863
- const validIds = new Set(tasks.map((task) => task.id));
864
- let changed = false;
865
- selectionState.ids.forEach((id) => {
866
- if (!validIds.has(id)) {
867
- selectionState.ids.delete(id);
868
- changed = true;
869
- }
870
- });
871
- if (changed) {
872
- updateSelectionUI();
873
- syncSelectionCheckboxes();
874
- }
875
- }
876
-
877
- function toggleSelection(taskId, isSelected) {
878
- if (isSelected) {
879
- selectionState.ids.add(taskId);
880
- } else {
881
- selectionState.ids.delete(taskId);
882
- }
883
- updateSelectionUI();
884
- syncSelectionCheckboxes();
885
- }
886
-
887
- function selectAllVisible() {
888
- if (!selectionState.lastVisibleIds.length) return;
889
- selectionState.lastVisibleIds.forEach((id) => selectionState.ids.add(id));
890
- updateSelectionUI();
891
- syncSelectionCheckboxes();
892
- }
893
-
894
- function clearSelection() {
895
- selectionState.ids.clear();
896
- updateSelectionUI();
897
- syncSelectionCheckboxes();
898
- }
899
-
900
- function syncSelectionCheckboxes() {
901
- const checkboxes = document.querySelectorAll("[data-action=\"select-task\"]");
902
- checkboxes.forEach((checkbox) => {
903
- const card = checkbox.closest("[data-task-id]");
904
- if (!card) return;
905
- const isSelected = selectionState.ids.has(card.dataset.taskId);
906
- checkbox.checked = isSelected;
907
- card.classList.toggle("kanban-card-selected", isSelected);
908
- });
909
- }
910
-
911
- function updateSelectionUI() {
912
- const count = selectionState.ids.size;
913
- if (selectionElements.counter) {
914
- selectionElements.counter.textContent = `選択中: ${count}枚`;
915
- }
916
- if (selectionElements.delete) {
917
- selectionElements.delete.disabled = count === 0;
918
- selectionElements.delete.setAttribute("aria-disabled", count === 0 ? "true" : "false");
919
- }
920
- if (selectionElements.clear) {
921
- selectionElements.clear.disabled = count === 0;
922
- }
923
- if (selectionElements.selectAll) {
924
- selectionElements.selectAll.disabled = selectionState.lastVisibleIds.length === 0;
925
- }
926
- }
927
-
928
- function deleteTask(taskId) {
929
- const task = tasks.find((item) => item.id === taskId);
930
- if (!task) return;
931
- if (!window.confirm("このタスクを削除しますか?")) return;
932
- selectionState.ids.delete(taskId);
933
- tasks = tasks.filter((item) => item.id !== taskId);
934
- persist();
935
- render();
936
- showMessage("タスクを削除しました。", "info");
937
- }
938
-
939
- function deleteSelectedTasks() {
940
- const ids = [...selectionState.ids];
941
- if (!ids.length) return;
942
- if (!window.confirm(`選択中の${ids.length}件を削除しますか?`)) return;
943
- const idSet = new Set(ids);
944
- tasks = tasks.filter((task) => !idSet.has(task.id));
945
- selectionState.ids.clear();
946
- persist();
947
- render();
948
- showMessage("選択したタスクを削除しました。", "info");
949
- }
950
- function loadFromStorage() {
951
- try {
952
- const raw = window.localStorage.getItem(STORAGE_KEY);
953
- if (!raw) return;
954
- const parsed = JSON.parse(raw);
955
- if (!Array.isArray(parsed)) return;
956
-
957
- tasks = parsed
958
- .filter((item) => typeof item === "object" && item !== null && item.id && item.title)
959
- .map((item) => normalizeTask(item));
960
- } catch (error) {
961
- console.warn("Failed to load tasks from localStorage:", error);
962
- tasks = [];
963
- }
964
- }
965
-
966
- function normalizeTask(item) {
967
- const status = columnSettings[item.status] ? item.status : "backlog";
968
- const createdAt = item.createdAt ?? new Date().toISOString();
969
- const normalized = {
970
- id: item.id,
971
- title: item.title,
972
- owner: item.owner ?? "",
973
- notes: item.notes ?? "",
974
- status,
975
- due: item.due ?? "",
976
- createdAt,
977
- tags: normalizeTags(item.tags),
978
- link: normalizeLink(item.link ?? item.url ?? "")
979
- };
980
-
981
- if (status === "done") {
982
- normalized.completedAt = item.completedAt ?? item.updatedAt ?? createdAt;
983
- } else if (item.completedAt) {
984
- normalized.completedAt = item.completedAt;
985
- }
986
-
987
- return normalized;
988
- }
989
-
990
- function normalizeTags(value) {
991
- if (Array.isArray(value)) {
992
- return parseTags(value.join(","));
993
- }
994
- if (typeof value === "string") {
995
- return parseTags(value);
996
- }
997
- return [];
998
- }
999
-
1000
- function persist() {
1001
- try {
1002
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
1003
- } catch (error) {
1004
- console.warn("Failed to write tasks to localStorage:", error);
1005
- showMessage("ローカルストレージに保存できませんでした。", "warn");
1006
- }
1007
- }
1008
-
1009
- function showMessage(message, tone = "info") {
1010
- if (!messageArea) return;
1011
- clearTimeout(messageTimer);
1012
- messageArea.textContent = message;
1013
- messageArea.classList.remove("opacity-0", "text-kanban-caution", "text-sky-300");
1014
- messageArea.classList.add(tone === "warn" ? "text-kanban-caution" : "text-sky-300", "opacity-100");
1015
- messageTimer = window.setTimeout(() => hideMessage(), 4200);
1016
- }
1017
-
1018
- function hideMessage() {
1019
- if (!messageArea) return;
1020
- messageArea.classList.add("opacity-0");
1021
- }
1022
-
1023
- function seedDemoData() {
1024
- const now = new Date();
1025
- const demoCards = [
1026
- {
1027
- title: "前日の不具合整理",
1028
- owner: "田中",
1029
- notes: "Eラインの停止要因を5Whyで再整理",
1030
- status: "backlog",
1031
- due: isoDateOffset(now, 1),
1032
- tags: ["品質", "改善"],
1033
- link: "https://example.com/quality-review"
1034
- },
1035
- {
1036
- title: "朝会ファシリテーション",
1037
- owner: "佐藤",
1038
- notes: "指標ボード更新と本日の重点テーマ共有",
1039
- status: "ready",
1040
- due: isoDateOffset(now, 0),
1041
- tags: ["コミュニケーション"],
1042
- link: "https://example.com/daily-meeting"
1043
- },
1044
- {
1045
- title: "標準作業票の更新",
1046
- owner: "鈴木",
1047
- notes: "工程C-12の新治具対応版。現場レビュー待ち。",
1048
- status: "waiting",
1049
- due: isoDateOffset(now, 3),
1050
- tags: ["標準化", "改善"],
1051
- link: "https://example.com/standard-work"
1052
- },
1053
- {
1054
- title: "トレーニング計画ドラフト",
1055
- owner: "山本",
1056
- notes: "新入社員向け。ヒアリング結果を反映中。",
1057
- status: "inProgress",
1058
- due: isoDateOffset(now, 5),
1059
- tags: ["人材育成"],
1060
- link: "https://example.com/training-plan"
1061
- },
1062
- {
1063
- title: "品質ロス分析",
1064
- owner: "石井",
1065
- notes: "先週分のデータを取り込み済み。残りは原因記入。",
1066
- status: "ready",
1067
- due: isoDateOffset(now, 2),
1068
- tags: ["品質", "分析"],
1069
- link: "https://example.com/quality-loss"
1070
- },
1071
- {
1072
- title: "安全KY実施",
1073
- owner: "加藤",
1074
- notes: "夜勤チームへ展開済み。記録は箱に保管。",
1075
- status: "done",
1076
- due: isoDateOffset(now, -1),
1077
- tags: ["安全", "教育"],
1078
- link: "https://example.com/safety-ky"
1079
- }
1080
- ];
1081
-
1082
- demoCards.forEach((card, index) => {
1083
- const desiredStatus = card.status;
1084
- const status = canEnterColumn(desiredStatus) ? desiredStatus : "backlog";
1085
- const createdAt = new Date(now.getTime() + index * 1000);
1086
- const entry = {
1087
- id: createId(),
1088
- title: card.title,
1089
- owner: card.owner,
1090
- notes: card.notes,
1091
- status,
1092
- due: card.due,
1093
- createdAt: createdAt.toISOString(),
1094
- tags: parseTags(card.tags?.join(",") ?? ""),
1095
- link: normalizeLink(card.link ?? "")
1096
- };
1097
-
1098
- if (status === "done") {
1099
- entry.completedAt = new Date(createdAt.getTime() + 60 * 60 * 1000).toISOString();
1100
- }
1101
-
1102
- tasks.push(entry);
1103
- });
1104
-
1105
- persist();
1106
- render();
1107
- showMessage("デモデータを追加しました。", "info");
1108
- }
1109
-
1110
- function isoDateOffset(date, offsetDays) {
1111
- const clone = new Date(date);
1112
- clone.setDate(clone.getDate() + offsetDays);
1113
- return clone.toISOString().slice(0, 10);
1114
- }
1115
-
1116
- function formatDue(value) {
1117
- if (!value) return "未設定";
1118
- try {
1119
- const date = new Date(value);
1120
- if (Number.isNaN(date.getTime())) return "未設定";
1121
- return `${date.getFullYear()}年${padZero(date.getMonth() + 1)}月${padZero(date.getDate())}日`;
1122
- } catch (_error) {
1123
- return "未設定";
1124
- }
1125
- }
1126
-
1127
- function padZero(num) {
1128
- return String(num).padStart(2, "0");
1129
- }
1130
-
1131
- function createId() {
1132
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1133
- return crypto.randomUUID();
1134
- }
1135
- return `task-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
1136
- }
1137
-
1138
- function openEditDialog(taskId) {
1139
- const task = tasks.find((item) => item.id === taskId);
1140
- if (!task) return;
1141
-
1142
- if (!editDialog || !editForm || !editFields) {
1143
- fallbackEdit(task);
1144
- return;
1145
- }
1146
-
1147
- editingTaskId = taskId;
1148
- editFields.title.value = task.title;
1149
- editFields.owner.value = task.owner ?? "";
1150
- editFields.due.value = task.due ?? "";
1151
- editFields.notes.value = task.notes ?? "";
1152
- if (editFields.tags) {
1153
- editFields.tags.value = Array.isArray(task.tags) ? task.tags.join(", ") : "";
1154
- }
1155
- editFields.link.value = task.link ?? "";
1156
-
1157
- try {
1158
- if (typeof editDialog.showModal === "function") {
1159
- editDialog.showModal();
1160
- } else {
1161
- editDialog.setAttribute("open", "true");
1162
- }
1163
- window.setTimeout(() => editFields.title.focus(), 0);
1164
- } catch (error) {
1165
- console.warn("Failed to open dialog, using fallback.", error);
1166
- fallbackEdit(task);
1167
- }
1168
- }
1169
-
1170
- function handleEditSubmit(event) {
1171
- event.preventDefault();
1172
- if (!editFields || !editingTaskId) return;
1173
-
1174
- const title = editFields.title.value.trim();
1175
- if (!title) {
1176
- showMessage("タイトルを入力してください。", "warn");
1177
- editFields.title.focus();
1178
- return;
1179
- }
1180
-
1181
- const updates = {
1182
- title,
1183
- owner: editFields.owner.value.trim(),
1184
- due: editFields.due.value,
1185
- notes: editFields.notes.value.trim(),
1186
- tags: parseTags(editFields.tags ? editFields.tags.value : ""),
1187
- link: normalizeLink(editFields.link?.value ?? "")
1188
- };
1189
-
1190
- applyTaskUpdates(editingTaskId, updates);
1191
- closeEditDialog();
1192
- }
1193
-
1194
- function applyTaskUpdates(taskId, updates) {
1195
- const task = tasks.find((item) => item.id === taskId);
1196
- if (!task) return false;
1197
-
1198
- task.title = updates.title;
1199
- task.owner = updates.owner;
1200
- task.due = updates.due;
1201
- task.notes = updates.notes;
1202
- task.tags = updates.tags;
1203
- task.link = updates.link;
1204
-
1205
- persist();
1206
- render();
1207
- showMessage("タスクを更新しました。", "info");
1208
- return true;
1209
- }
1210
-
1211
- function closeEditDialog(fromEvent = false) {
1212
- if (!editDialog) return;
1213
-
1214
- if (!fromEvent) {
1215
- if (typeof editDialog.close === "function" && editDialog.open) {
1216
- editDialog.close();
1217
- } else {
1218
- editDialog.removeAttribute("open");
1219
- }
1220
- } else {
1221
- editDialog.removeAttribute("open");
1222
- }
1223
-
1224
- resetEditingState();
1225
- }
1226
-
1227
- function resetEditingState() {
1228
- editingTaskId = null;
1229
- editForm?.reset();
1230
- }
1231
-
1232
- function fallbackEdit(task) {
1233
- const title = window.prompt("タイトルを編集", task.title);
1234
- if (title === null) return;
1235
- const trimmedTitle = title.trim();
1236
- if (!trimmedTitle) {
1237
- showMessage("タイトルを入力してください。", "warn");
1238
- return;
1239
- }
1240
-
1241
- const owner = window.prompt("担当を編集", task.owner ?? "") ?? task.owner ?? "";
1242
- const due = window.prompt("期限 (YYYY-MM-DD)", task.due ?? "") ?? task.due ?? "";
1243
- const notes = window.prompt("メモ", task.notes ?? "") ?? task.notes ?? "";
1244
- const tagsInput = window.prompt("タグ(カンマ区切り)", Array.isArray(task.tags) ? task.tags.join(", ") : "") ?? (Array.isArray(task.tags) ? task.tags.join(", ") : "");
1245
- const linkInput = window.prompt("参考リンク(URL)", task.link ?? "") ?? (task.link ?? "");
1246
-
1247
- applyTaskUpdates(task.id, {
1248
- title: trimmedTitle,
1249
- owner: owner.trim(),
1250
- due,
1251
- notes: notes.trim(),
1252
- tags: parseTags(tagsInput),
1253
- link: normalizeLink(linkInput)
1254
- });
1255
- }
1256
-
1257
- function toggleViewMode() {
1258
- uiState.compact = !uiState.compact;
1259
- updateViewToggleLabel();
1260
- render();
1261
- showMessage(uiState.compact ? "コンパクト表示に切り替えました。" : "標準表示に戻しました。", "info");
1262
- }
1263
-
1264
- function updateViewToggleLabel() {
1265
- if (!uiElements.viewToggle) return;
1266
- uiElements.viewToggle.textContent = uiState.compact ? "標準表示" : "コンパクト表示";
1267
- }
1268
-
1269
- function parseISODate(value) {
1270
- if (!value) return null;
1271
- try {
1272
- const date = new Date(value);
1273
- return Number.isNaN(date.getTime()) ? null : new Date(date.getFullYear(), date.getMonth(), date.getDate());
1274
- } catch (_error) {
1275
- return null;
1276
- }
1277
- }
1278
-
1279
- function formatLinkLabel(url) {
1280
- try {
1281
- const parsed = new URL(url);
1282
- const path = parsed.pathname === "/" ? "" : parsed.pathname;
1283
- return `${parsed.hostname}${path}`;
1284
- } catch (_error) {
1285
- return url;
1286
- }
1287
- }
1288
-
1289
- function normalizeLink(input) {
1290
- if (!input) return "";
1291
- let raw0 = String(input).trim();
1292
- if (!raw0) return "";
1293
-
1294
- // ✅ 追加: 先頭・末尾のダブルクォーテーションを削除
1295
- raw0 = raw0.replace(/^"(.*)"$/, "$1");
1296
-
1297
- // 全角の「¥」をバックスラッシュに誤入力しているケースを補正
1298
- const raw = raw0.replace(/¥/g, "\\");
1299
-
1300
- const ensureDirSlash = (u) => {
1301
- try {
1302
- const url = new URL(u);
1303
- if (url.protocol !== "file:") return u;
1304
- if (url.pathname.endsWith("/")) return u;
1305
-
1306
- const last = decodeURI(url.pathname).split("/").pop() || "";
1307
- const looksLikeFile = /\.[A-Za-z0-9]{1,6}$/.test(last);
1308
- return looksLikeFile ? u : (u + "/");
1309
- } catch {
1310
- return u;
1311
- }
1312
- };
1313
-
1314
- // 1) Windows ドライブパス: C:\... → file:///C:/...
1315
- if (/^[a-zA-Z]:\\/.test(raw)) {
1316
- const path = raw.replace(/\\/g, "/");
1317
- return ensureDirSlash(`file:///${encodeURI(path)}`);
1318
- }
1319
-
1320
- // 2) UNC: \\server\share\path → file://server/share/path
1321
- if (/^\\\\[^\\]/.test(raw)) {
1322
- const path = raw.replace(/^\\\\/, "").replace(/\\/g, "/");
1323
- return ensureDirSlash(`file://${encodeURI(path)}`);
1324
- }
1325
-
1326
- // 3) 明示スキームあり(http/https/file)
1327
- const hasScheme = /^[a-zA-Z][\\w+.-]*:/.test(raw);
1328
- if (hasScheme) {
1329
- try {
1330
- const url = new URL(raw);
1331
- if (/^https?:$/i.test(url.protocol)) return url.href;
1332
- if (/^file:$/i.test(url.protocol)) return ensureDirSlash(url.href);
1333
- return "";
1334
- } catch {
1335
- return "";
1336
- }
1337
- }
1338
-
1339
- // 4) スキーム無し → Webドメインらしければ https:// を補完
1340
- try {
1341
- const candidate = `https://${raw}`;
1342
- const url = new URL(candidate);
1343
- return url.href;
1344
- } catch {
1345
- return "";
1346
- }
1347
- }
1348
-
1349
-
1350
- function parseTags(input) {
1351
- if (!input) return [];
1352
- const parts = Array.isArray(input) ? input : String(input).split(/[,、\s]+/);
1353
- const seen = new Set();
1354
- const result = [];
1355
- parts
1356
- .map((item) => String(item).trim())
1357
- .filter(Boolean)
1358
- .forEach((tag) => {
1359
- const cleaned = tag.replace(/^#/, "").trim();
1360
- if (!cleaned) return;
1361
- const key = cleaned.toLowerCase();
1362
- if (seen.has(key)) return;
1363
- seen.add(key);
1364
- result.push(cleaned);
1365
- });
1366
- return result;
1367
- }
1368
- })();
1369
-
1370
-
1371
-
1372
-
1373
-
1374
-
1375
-
1376
-
1377
-
1378
-
1379
-
1380
-
1381
-
1382
-
1383
-
1384
-
1385
-
1386
-
1387
-
1388
-
1389
-
1390
-
1391
-
1392
-
1393
-
1394
-
1395
-
1396
-
1397
-
1398
-
1399
-
1400
-
1401
-
1402
-
1403
-
1404
-
1405
-
1406
-
1407
-
1408
-
1409
-
1410
-
1411
-
1412
-
1413
-