fix: tolerate colliding frontmatter properties

This commit is contained in:
lucaronin
2026-05-01 18:09:48 +02:00
parent 5b2b4cd569
commit 6e41328f21
25 changed files with 294 additions and 84 deletions

View File

@@ -11,7 +11,7 @@ files:
command.openSettings: 638c05f4f159a403e04aebe94b46a2a8
command.openSettings.keywords: ab6b5e03395f4db955ed9cbfd4de33b7
command.openLanguageSettings: e5a425aa6222fab3df66c0e1e0ca4183
command.openLanguageSettings.keywords: 639afefdd1c238b381bc1f963288f389
command.openLanguageSettings.keywords: 6a910000cbf23eac3c7078e518cae66a
command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca
command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf
command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
@@ -385,6 +385,8 @@ files:
inspector.title.properties: 9fc2d28c05ed9eb1d75ba4465abf15a9
inspector.title.propertiesShortcut: 427d1cbdc813cf54c5fac2508d38d407
inspector.title.closePropertiesShortcut: 97b953e45042d4143dd19624dc345d29
inspector.title.collidingProperties: bf71358d0af34696e88bea278eac046a
inspector.title.collidingPropertiesAria: ce5c3569f304cd69b0f33a6fc185f49b
inspector.empty.noNoteSelected: 046d95682b747a30ed8a7b0b1d581629
inspector.empty.noProperties: 6864166e0f65d81b1eb474e41fde8822
inspector.empty.initializeProperties: edf249d214b68f5afe8634c577b709c4
@@ -525,3 +527,4 @@ files:
locale.jaJP: f32ced6a9ba164c4b3c047fd1d7c882e
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb
locale.vi: 7b80fae85640c16cdb0261bef0c27636
locale.plPL: c730389bc8d99e59c867766babdd48b5

View File

@@ -182,69 +182,78 @@ fn sanitize_value(value: &serde_json::Value) -> serde_json::Value {
}
}
fn type_key_priority(key: &str) -> u8 {
match key {
"type" => 0,
"Type" => 1,
"TYPE" => 2,
_ => 3,
fn canonical_known_key(key: &str) -> Option<&'static str> {
let trimmed = key.trim();
if trimmed.eq_ignore_ascii_case("type") {
return Some("type");
}
match trimmed {
"title" => Some("title"),
"Is A" | "is_a" => Some("type"),
"aliases" => Some("aliases"),
"_archived" | "Archived" | "archived" => Some("_archived"),
"_icon" | "icon" => Some("_icon"),
"color" => Some("color"),
"_order" | "order" => Some("_order"),
"_sidebar_label" | "sidebar_label" | "sidebar label" => Some("_sidebar_label"),
"template" => Some("template"),
"_sort" | "sort" => Some("_sort"),
"view" => Some("view"),
"_width" | "width" => Some("_width"),
"visible" => Some("visible"),
"Status" | "status" => Some("Status"),
"_organized" => Some("_organized"),
"_favorite" => Some("_favorite"),
"_favorite_index" => Some("_favorite_index"),
"_list_properties_display" => Some("_list_properties_display"),
_ => None,
}
}
fn find_type_value(data: &HashMap<String, serde_json::Value>) -> Option<&serde_json::Value> {
data.iter()
.filter(|(key, _)| key.eq_ignore_ascii_case("type"))
.min_by_key(|(key, _)| type_key_priority(key))
.map(|(_, value)| value)
fn insert_known_frontmatter_value(
target: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: &serde_json::Value,
overwrite: bool,
) {
let Some(canonical_key) = canonical_known_key(key) else {
return;
};
if overwrite || !target.contains_key(canonical_key) {
target.insert(canonical_key.to_string(), sanitize_value(value));
}
}
fn raw_frontmatter_keys(raw_content: &str) -> Vec<String> {
RawFrontmatter(raw_content)
.extract_block()
.map(|raw| {
raw.lines()
.filter_map(|line| YamlLine(line).key().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn known_frontmatter_map(
data: &HashMap<String, serde_json::Value>,
raw_content: &str,
) -> serde_json::Map<String, serde_json::Value> {
let mut filtered = serde_json::Map::new();
for key in raw_frontmatter_keys(raw_content) {
if let Some(value) = data.get(&key) {
insert_known_frontmatter_value(&mut filtered, &key, value, true);
}
}
for (key, value) in data {
insert_known_frontmatter_value(&mut filtered, key, value, false);
}
filtered
}
/// Parse frontmatter from raw YAML data extracted by gray_matter.
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
static KNOWN_KEYS: &[&str] = &[
"title",
"type",
"Is A",
"is_a",
"aliases",
"_archived",
"Archived",
"archived",
"_icon",
"icon",
"color",
"_order",
"order",
"_sidebar_label",
"sidebar_label",
"sidebar label",
"template",
"_sort",
"sort",
"view",
"_width",
"width",
"visible",
"notion_id",
"Status",
"status",
"_organized",
"_favorite",
"_favorite_index",
"_list_properties_display",
];
let mut filtered = serde_json::Map::new();
if let Some(value) = find_type_value(data) {
filtered.insert("type".to_string(), sanitize_value(value));
}
for (key, value) in data {
if key.eq_ignore_ascii_case("type") || !KNOWN_KEYS.contains(&key.as_str()) {
continue;
}
filtered.insert(key.clone(), sanitize_value(value));
}
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>, raw_content: &str) -> Frontmatter {
let filtered = known_frontmatter_map(data, raw_content);
let value = serde_json::Value::Object(filtered);
serde_json::from_value(value).unwrap_or_default()
}
@@ -443,7 +452,7 @@ impl<'a> YamlLine<'a> {
return None;
}
let (key, _) = self.0.split_once(':')?;
Some(key.trim().trim_matches('"'))
Some(key.trim().trim_matches('"').trim_matches('\''))
}
fn list_item(self) -> Option<&'a str> {
@@ -543,7 +552,7 @@ pub(crate) fn extract_fm_and_rels(
}
};
(
parse_frontmatter(&json_map),
parse_frontmatter(&json_map, raw_content),
extract_relationships(&json_map),
extract_properties(&json_map),
)

View File

@@ -133,3 +133,13 @@ fn test_alias_parser_recovers_special_alias_items() {
assert_alias_parser_recovers(case);
}
}
#[test]
fn test_alias_collisions_keep_frontmatter_with_last_value_winning() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Note\nstatus: Active\nStatus: Evergreened\n---\n# Test\n";
let entry = parse_test_entry(&dir, "test.md", content);
assert_eq!(entry.is_a, Some("Note".to_string()));
assert_eq!(entry.status, Some("Evergreened".to_string()));
}

View File

@@ -3,6 +3,8 @@ import { describe, it, expect, vi } from 'vitest'
import { CreateViewDialog } from './CreateViewDialog'
import type { ViewDefinition } from '../types'
const DIALOG_TEST_TIMEOUT_MS = 10_000
describe('CreateViewDialog', () => {
const defaultProps = {
open: true,
@@ -26,7 +28,7 @@ describe('CreateViewDialog', () => {
render(<CreateViewDialog {...defaultProps} />)
expect(screen.getByText('Create View')).toBeInTheDocument()
expect(screen.getByText('Create')).toBeInTheDocument()
})
}, DIALOG_TEST_TIMEOUT_MS)
it('shows "Edit View" title when editingView is provided', () => {
render(<CreateViewDialog {...defaultProps} editingView={makeEditingView()} />)

View File

@@ -129,6 +129,26 @@ describe('Inspector', () => {
expect(onToggle).toHaveBeenCalledOnce()
})
it('shows a colliding-properties warning that opens the raw editor', async () => {
const onToggleRawEditor = vi.fn()
const content = `---
type: Note
status: Active
Status: Evergreened
---
# Test Project
`
renderSelectedInspector({ content, onToggleRawEditor })
const warning = screen.getByRole('button', { name: 'Colliding properties. Open raw editor.' })
fireEvent.focus(warning)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Colliding properties')
fireEvent.click(warning)
expect(onToggleRawEditor).toHaveBeenCalledOnce()
})
it('shows properties when a note is selected', () => {
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
expect(screen.getAllByText('Project').length).toBeGreaterThan(0)

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react'
import type { VaultEntry, GitCommit } from '../types'
import { cn } from '@/lib/utils'
import { Separator } from './ui/separator'
import { parseFrontmatter, detectFrontmatterState } from '../utils/frontmatter'
import { parseFrontmatter, detectFrontmatterState, detectFrontmatterWarnings } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import {
DynamicRelationshipsPanel,
@@ -247,9 +247,20 @@ function InspectorBody({
}
export function Inspector({ collapsed, onToggle, ...bodyProps }: InspectorProps) {
const frontmatterWarnings = useMemo(
() => detectFrontmatterWarnings(bodyProps.content),
[bodyProps.content],
)
return (
<aside className={cn('flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200', collapsed && '!w-10 !min-w-10')}>
<InspectorHeader collapsed={collapsed} locale={bodyProps.locale} onToggle={onToggle} />
<InspectorHeader
collapsed={collapsed}
frontmatterWarnings={frontmatterWarnings}
locale={bodyProps.locale}
onToggle={onToggle}
onOpenRawEditor={bodyProps.onToggleRawEditor}
/>
{!collapsed && (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
<InspectorBody {...bodyProps} />

View File

@@ -2,6 +2,8 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LinuxMenuButton } from './LinuxMenuButton'
const MENU_TEST_TIMEOUT_MS = 10_000
const { close, invoke, minimize, toggleMaximize } = vi.hoisted(() => ({
invoke: vi.fn().mockResolvedValue(undefined),
minimize: vi.fn().mockResolvedValue(undefined),
@@ -41,7 +43,7 @@ describe('LinuxMenuButton', () => {
expect(screen.getByText('Ctrl+Shift+L')).toBeInTheDocument()
fireEvent.click(await screen.findByText('Toggle AI Panel'))
expect(invoke).toHaveBeenCalledWith('trigger_menu_command', { id: 'view-toggle-ai-chat' })
})
}, MENU_TEST_TIMEOUT_MS)
it('invokes direct window actions from the Window submenu', async () => {
render(<LinuxMenuButton />)

View File

@@ -1,10 +1,40 @@
import { SlidersHorizontal, X, Sparkle, WarningCircle, PencilSimple } from '@phosphor-icons/react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import { useDragRegion } from '../../hooks/useDragRegion'
import { translate, type AppLocale } from '../../lib/i18n'
import { hasFrontmatterWarnings, type FrontmatterWarnings } from '../../utils/frontmatter'
export function InspectorHeader({ collapsed, locale = 'en', onToggle }: { collapsed: boolean; locale?: AppLocale; onToggle: () => void }) {
function FrontmatterWarningsButton({ locale, onOpenRawEditor }: { locale: AppLocale; onOpenRawEditor: () => void }) {
const label = translate(locale, 'inspector.title.collidingProperties')
return (
<ActionTooltip copy={{ label }} side="bottom">
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 shrink-0 rounded-md border border-[var(--feedback-warning-border)] bg-[var(--feedback-warning-bg)] p-0 text-[var(--feedback-warning-text)] shadow-none hover:brightness-95"
aria-label={translate(locale, 'inspector.title.collidingPropertiesAria')}
data-testid="frontmatter-warnings-button"
onMouseDown={(event) => event.stopPropagation()}
onClick={onOpenRawEditor}
>
<WarningCircle size={14} weight="fill" aria-hidden="true" />
</Button>
</ActionTooltip>
)
}
export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en', onToggle, onOpenRawEditor }: {
collapsed: boolean
frontmatterWarnings?: FrontmatterWarnings
locale?: AppLocale
onToggle: () => void
onOpenRawEditor?: () => void
}) {
const { onMouseDown } = useDragRegion()
const propertiesTitle = translate(locale, 'inspector.title.properties')
const showWarnings = Boolean(frontmatterWarnings && hasFrontmatterWarnings(frontmatterWarnings) && onOpenRawEditor)
return (
<div
@@ -23,7 +53,11 @@ export function InspectorHeader({ collapsed, locale = 'en', onToggle }: { collap
) : (
<>
<SlidersHorizontal size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>{propertiesTitle}</span>
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>{propertiesTitle}</span>
{showWarnings && onOpenRawEditor && (
<FrontmatterWarningsButton locale={locale} onOpenRawEditor={onOpenRawEditor} />
)}
<span className="flex-1" />
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}

View File

@@ -9,7 +9,7 @@
"command.openSettings": "Einstellungen öffnen",
"command.openSettings.keywords": "Einstellungen Konfiguration",
"command.openLanguageSettings": "Spracheinstellungen öffnen",
"command.openLanguageSettings.keywords": "Sprache Gebietsschema i18n Internationalisierung Lokalisierung Englisch Italienisch Französisch Deutsch Russisch Spanisch Portugiesisch Chinesisch Vereinfacht Traditionell Japanisch Koreanisch 中文 繁體中文 zh-tw",
"command.openLanguageSettings.keywords": "Sprache Gebietsschema i18n Internationalisierung Lokalisierung Englisch Italienisch Französisch Deutsch Russisch Spanisch Portugiesisch Chinesisch vereinfacht traditionell Japanisch Koreanisch Polnisch 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "Systemsprache verwenden",
"command.openH1Setting": "Einstellung für automatische H1-Umbenennung öffnen",
"command.toggleGitignoredFilesVisibility": "Sichtbarkeit von Gitignored-Dateien umschalten",
@@ -383,6 +383,8 @@
"inspector.title.properties": "Eigenschaften",
"inspector.title.propertiesShortcut": "Eigenschaften (⌘⇧I)",
"inspector.title.closePropertiesShortcut": "Eigenschaften schließen (⌘⇧I)",
"inspector.title.collidingProperties": "Kollidierende Eigenschaften",
"inspector.title.collidingPropertiesAria": "Kollidierende Eigenschaften. Roh-Editor öffnen.",
"inspector.empty.noNoteSelected": "Keine Notiz ausgewählt",
"inspector.empty.noProperties": "Diese Notiz hat noch keine Eigenschaften",
"inspector.empty.initializeProperties": "Eigenschaften initialisieren",

View File

@@ -383,6 +383,8 @@
"inspector.title.properties": "Properties",
"inspector.title.propertiesShortcut": "Properties (⌘⇧I)",
"inspector.title.closePropertiesShortcut": "Close Properties (⌘⇧I)",
"inspector.title.collidingProperties": "Colliding properties",
"inspector.title.collidingPropertiesAria": "Colliding properties. Open raw editor.",
"inspector.empty.noNoteSelected": "No note selected",
"inspector.empty.noProperties": "This note has no properties yet",
"inspector.empty.initializeProperties": "Initialize properties",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "Abrir Configuración",
"command.openSettings.keywords": "configuración de preferencias",
"command.openLanguageSettings": "Abrir la configuración de idioma",
"command.openLanguageSettings.keywords": "idioma configuración regional i18n internacionalización localización inglés italiano francés alemán ruso español portugués chino simplificado tradicional japonés coreano 中文 繁體中文 zh-tw",
"command.openLanguageSettings.keywords": "idioma configuración regional i18n internacionalización localización inglés italiano francés alemán ruso español portugués chino simplificado tradicional japonés coreano polaco 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "Usar el idioma del sistema",
"command.openH1Setting": "Abrir la configuración de renombrado automático de H1",
"command.toggleGitignoredFilesVisibility": "Alternar la visibilidad de los archivos Gitignored",
@@ -383,6 +383,8 @@
"inspector.title.properties": "Propiedades",
"inspector.title.propertiesShortcut": "Propiedades (⌘⇧I)",
"inspector.title.closePropertiesShortcut": "Cerrar Propiedades (⌘⇧I)",
"inspector.title.collidingProperties": "Propiedades en conflicto",
"inspector.title.collidingPropertiesAria": "Propiedades en conflicto. Abrir editor sin formato.",
"inspector.empty.noNoteSelected": "No se seleccionó ninguna nota",
"inspector.empty.noProperties": "Esta nota aún no tiene propiedades",
"inspector.empty.initializeProperties": "Inicializar propiedades",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "Abrir Ajustes",
"command.openSettings.keywords": "preferencias config",
"command.openLanguageSettings": "Abrir la configuración de idioma",
"command.openLanguageSettings.keywords": "idioma configuración regional i18n internacionalización localización inglés italiano francés alemán ruso español portugués chino simplificado tradicional japonés coreano 中文 繁體中文 zh-tw",
"command.openLanguageSettings.keywords": "idioma configuración regional i18n internacionalización localización inglés italiano francés alemán ruso español portugués chino simplificado tradicional japonés coreano polaco 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "Usar el idioma del sistema",
"command.openH1Setting": "Abrir la configuración de renombrado automático de H1",
"command.toggleGitignoredFilesVisibility": "Activar/desactivar la visibilidad de los archivos Gitignored",
@@ -383,6 +383,8 @@
"inspector.title.properties": "Propiedades",
"inspector.title.propertiesShortcut": "Propiedades (⌘⇧I)",
"inspector.title.closePropertiesShortcut": "Cerrar Propiedades (⌘⇧I)",
"inspector.title.collidingProperties": "Propiedades en conflicto",
"inspector.title.collidingPropertiesAria": "Propiedades en conflicto. Abrir editor sin formato.",
"inspector.empty.noNoteSelected": "Ninguna nota seleccionada",
"inspector.empty.noProperties": "Esta nota aún no tiene propiedades",
"inspector.empty.initializeProperties": "Inicializar propiedades",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "Ouvrir les paramètres",
"command.openSettings.keywords": "préférences config",
"command.openLanguageSettings": "Ouvrir les paramètres de langue",
"command.openLanguageSettings.keywords": "langue paramètres régionaux i18n internationalisation localisation anglais italien français allemand russe espagnol portugais chinois simplifié traditionnel japonais coréen 中文 繁體中文 zh-tw",
"command.openLanguageSettings.keywords": "langue paramètres régionaux i18n internationalisation localisation anglais italien français allemand russe espagnol portugais chinois simplifié traditionnel japonais coréen polonais 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "Utiliser la langue du système",
"command.openH1Setting": "Ouvrir le paramètre de renommage automatique des titres H1",
"command.toggleGitignoredFilesVisibility": "Activer/désactiver la visibilité des fichiers Gitignored",
@@ -383,6 +383,8 @@
"inspector.title.properties": "Propriétés",
"inspector.title.propertiesShortcut": "Propriétés (⌘⇧I)",
"inspector.title.closePropertiesShortcut": "Fermer les propriétés (⌘⇧I)",
"inspector.title.collidingProperties": "Propriétés en conflit",
"inspector.title.collidingPropertiesAria": "Propriétés en conflit. Ouvrir l'éditeur brut.",
"inspector.empty.noNoteSelected": "Aucune note sélectionnée",
"inspector.empty.noProperties": "Cette note n'a pas encore de propriétés",
"inspector.empty.initializeProperties": "Initialiser les propriétés",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "Apri le impostazioni",
"command.openSettings.keywords": "preferences config",
"command.openLanguageSettings": "Apri le impostazioni della lingua",
"command.openLanguageSettings.keywords": "lingua locale i18n internazionalizzazione localizzazione inglese italiano francese tedesco russo spagnolo portoghese cinese semplificato tradizionale giapponese coreano 中文 繁體中文 zh-tw",
"command.openLanguageSettings.keywords": "lingua locale i18n internazionalizzazione localizzazione inglese italiano francese tedesco russo spagnolo portoghese cinese semplificato tradizionale giapponese coreano polacco 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "Usa la lingua di sistema",
"command.openH1Setting": "Apri l'impostazione di rinomina automatica H1",
"command.toggleGitignoredFilesVisibility": "Attiva/disattiva la visibilità dei file Gitignored",
@@ -383,6 +383,8 @@
"inspector.title.properties": "Proprietà",
"inspector.title.propertiesShortcut": "Proprietà (⌘<>⇧I)",
"inspector.title.closePropertiesShortcut": "Chiudi Proprietà (⌘⇧I)",
"inspector.title.collidingProperties": "Proprietà in conflitto",
"inspector.title.collidingPropertiesAria": "Proprietà in conflitto. Apri l'editor raw.",
"inspector.empty.noNoteSelected": "Nessuna nota selezionata",
"inspector.empty.noProperties": "Questa nota non ha ancora proprietà",
"inspector.empty.initializeProperties": "Inizializza proprietà",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "設定を開く",
"command.openSettings.keywords": "preferences config",
"command.openLanguageSettings": "言語設定を開く",
"command.openLanguageSettings.keywords": "言語ロケールi18n国際化ローカライゼーション英語イタリア語フランス語ドイツ語ロシア語スペイン語ポルトガル語中国語簡体)、中国語(繁体)、日本語韓国語中文繁體)、zh-tw",
"command.openLanguageSettings.keywords": "言語 ロケール i18n 国際化 ローカライゼーション 英語 イタリア語 フランス語 ドイツ語 ロシア語 スペイン語 ポルトガル語 中国語 簡体字 繁体字 日本語 韓国語 ポーランド語 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "システム言語を使用",
"command.openH1Setting": "H1 自動名前変更設定を開く",
"command.toggleGitignoredFilesVisibility": "Gitignoredファイルの表示/非表示を切り替える",
@@ -383,6 +383,8 @@
"inspector.title.properties": "プロパティ",
"inspector.title.propertiesShortcut": "プロパティ⌘⇧I",
"inspector.title.closePropertiesShortcut": "プロパティを閉じる⌘⇧I",
"inspector.title.collidingProperties": "競合するプロパティ",
"inspector.title.collidingPropertiesAria": "競合するプロパティ。Rawエディターを開く。",
"inspector.empty.noNoteSelected": "ノートが選択されていません",
"inspector.empty.noProperties": "このノートにはまだプロパティがありません",
"inspector.empty.initializeProperties": "プロパティを初期化",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "설정 열기",
"command.openSettings.keywords": "preferences config",
"command.openLanguageSettings": "언어 설정 열기",
"command.openLanguageSettings.keywords": "언어 로케일 i18n 국제화 현지화 영어 이탈리아어 프랑스어 독일어 러시아어 스페인어 포르투갈어 중국어 간체 중국어 번체 일본어 한국어 中文 繁體中文 zh-tw",
"command.openLanguageSettings.keywords": "언어 로케일 i18n 국제화 현지화 영어 이탈리아어 프랑스어 독일어 러시아어 스페인어 포르투갈어 중국어 간체 중국어 번체 일본어 한국어 폴란드어 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "시스템 언어 사용",
"command.openH1Setting": "H1 자동 이름 변경 설정 열기",
"command.toggleGitignoredFilesVisibility": "Gitignored 파일 표시/숨기기",
@@ -383,6 +383,8 @@
"inspector.title.properties": "속성",
"inspector.title.propertiesShortcut": "속성(⌘⇧I)",
"inspector.title.closePropertiesShortcut": "속성 닫기 (⌘⇧I)",
"inspector.title.collidingProperties": "충돌하는 속성",
"inspector.title.collidingPropertiesAria": "충돌하는 속성. 원시 편집기를 엽니다.",
"inspector.empty.noNoteSelected": "선택한 노트 없음",
"inspector.empty.noProperties": "이 노트에는 아직 속성이 없습니다.",
"inspector.empty.initializeProperties": "속성 초기화",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "Otwórz ustawienia",
"command.openSettings.keywords": "preferencje konfiguracja",
"command.openLanguageSettings": "Otwórz ustawienia języka",
"command.openLanguageSettings.keywords": "język lokalizacja i18n internacjonalizacja angielski włoski francuski niemiecki rosyjski hiszpański portugalski chiński uproszczony tradycyjny japoński koreański polski 中文 繁體中文 zh-tw",
"command.openLanguageSettings.keywords": "język ustawienia regionalne i18n internacjonalizacja lokalizacja angielski włoski francuski niemiecki rosyjski hiszpański portugalski chiński uproszczony tradycyjny japoński koreański polski 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "Użyj języka systemowego",
"command.openH1Setting": "Otwórz ustawienie automatycznej zmiany nazwy H1",
"command.toggleGitignoredFilesVisibility": "Przełącz widoczność plików ignorowanych przez Git",
@@ -383,6 +383,8 @@
"inspector.title.properties": "Właściwości",
"inspector.title.propertiesShortcut": "Właściwości (⌘⇧I)",
"inspector.title.closePropertiesShortcut": "Zamknij Właściwości (⌘⇧I)",
"inspector.title.collidingProperties": "Kolidujące właściwości",
"inspector.title.collidingPropertiesAria": "Kolidujące właściwości. Otwórz edytor surowy.",
"inspector.empty.noNoteSelected": "Nie wybrano notatki",
"inspector.empty.noProperties": "Ta notatka nie ma jeszcze właściwości",
"inspector.empty.initializeProperties": "Zainicjalizuj właściwości",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "Abrir configurações",
"command.openSettings.keywords": "configuração de preferências",
"command.openLanguageSettings": "Abrir configurações de idioma",
"command.openLanguageSettings.keywords": "idioma localidade i18n internacionalização localização inglês italiano francês alemão russo espanhol português chinês simplificado tradicional japonês coreano 中文 繁體中文 zh-tw",
"command.openLanguageSettings.keywords": "idioma localização i18n internacionalização localização inglês italiano francês alemão russo espanhol português chinês simplificado tradicional japonês coreano polonês 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "Usar o idioma do sistema",
"command.openH1Setting": "Abrir configuração de renomeação automática de H1",
"command.toggleGitignoredFilesVisibility": "Alternar visibilidade dos arquivos Gitignored",
@@ -383,6 +383,8 @@
"inspector.title.properties": "Propriedades",
"inspector.title.propertiesShortcut": "Propriedades (⌘⇧I)",
"inspector.title.closePropertiesShortcut": "Fechar Propriedades (⌘⇧I)",
"inspector.title.collidingProperties": "Propriedades conflitantes",
"inspector.title.collidingPropertiesAria": "Propriedades conflitantes. Abrir editor bruto.",
"inspector.empty.noNoteSelected": "Nenhuma nota selecionada",
"inspector.empty.noProperties": "Esta nota ainda não tem propriedades",
"inspector.empty.initializeProperties": "Inicializar propriedades",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "Abrir Definições",
"command.openSettings.keywords": "configuração de preferências",
"command.openLanguageSettings": "Abrir definições de idioma",
"command.openLanguageSettings.keywords": "idioma localização i18n internacionalização localização inglês italiano francês alemão russo espanhol português chinês simplificado tradicional japonês coreano 中文 繁體中文 zh-tw",
"command.openLanguageSettings.keywords": "idioma região i18n internacionalização localização inglês italiano francês alemão russo espanhol português chinês simplificado tradicional japonês coreano polaco 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "Utilizar o idioma do sistema",
"command.openH1Setting": "Abrir a definição de renomeação automática de H1",
"command.toggleGitignoredFilesVisibility": "Alternar a visibilidade dos ficheiros Gitignored",
@@ -383,6 +383,8 @@
"inspector.title.properties": "Propriedades",
"inspector.title.propertiesShortcut": "Propriedades (⌘⇧I)",
"inspector.title.closePropertiesShortcut": "Fechar Propriedades (⌘⇧I)",
"inspector.title.collidingProperties": "Propriedades em conflito",
"inspector.title.collidingPropertiesAria": "Propriedades em conflito. Abrir editor bruto.",
"inspector.empty.noNoteSelected": "Nenhuma nota selecionada",
"inspector.empty.noProperties": "Esta nota ainda não tem propriedades",
"inspector.empty.initializeProperties": "Inicializar propriedades",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "Открыть настройки",
"command.openSettings.keywords": "Настройки конфигурации",
"command.openLanguageSettings": "Открыть настройки языка",
"command.openLanguageSettings.keywords": "Язык Регион i18n Интернационализация Локализация Английский Итальянский Французский Немецкий Русский Испанский Португальский Китайский (упрощенный) Китайский (традиционный) Японский Корейский 中文 繁體中文 zh-tw",
"command.openLanguageSettings.keywords": "Язык Регион i18n Интернационализация Локализация Английский Итальянский Французский Немецкий Русский Испанский Португальский Китайский (упрощенный) Китайский (традиционный) Японский Корейский Польский 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "Использовать язык системы",
"command.openH1Setting": "Открыть настройку автоматического переименования H1",
"command.toggleGitignoredFilesVisibility": "Переключить отображение файлов Gitignored",
@@ -383,6 +383,8 @@
"inspector.title.properties": "Свойства",
"inspector.title.propertiesShortcut": "Свойства (⌘⇧I)",
"inspector.title.closePropertiesShortcut": "Закрыть свойства (⌘⇧I)",
"inspector.title.collidingProperties": "Конфликтующие свойства",
"inspector.title.collidingPropertiesAria": "Конфликтующие свойства. Открыть редактор исходного текста.",
"inspector.empty.noNoteSelected": "Ни одна заметка не выбрана",
"inspector.empty.noProperties": "У этой заметки пока нет свойств",
"inspector.empty.initializeProperties": "Инициализировать свойства",

View File

@@ -278,6 +278,8 @@
"inspector.title.properties": "Thuộc tính",
"inspector.title.propertiesShortcut": "Thuộc tính (⌘⇧I)",
"inspector.title.closePropertiesShortcut": "Đóng thuộc tính (⌘⇧I)",
"inspector.title.collidingProperties": "Thuộc tính xung đột",
"inspector.title.collidingPropertiesAria": "Thuộc tính xung đột. Mở trình soạn thảo thô.",
"inspector.empty.noNoteSelected": "Chưa chọn ghi chú nào",
"inspector.empty.noProperties": "Ghi chú này vẫn chưa có thuộc tính",
"inspector.empty.initializeProperties": "Khởi tạo thuộc tính",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "打开设置",
"command.openSettings.keywords": "设置 偏好 配置",
"command.openLanguageSettings": "打开语言设置",
"command.openLanguageSettings.keywords": "语言 区域设置 i18n 国际化 本地化 英语 意大利语 法语 德语 俄语 西班牙语 葡萄牙语 中文 简体中文 繁体中文 日语 韩语 中文 繁中文 zh-tw",
"command.openLanguageSettings.keywords": "语言 区域设置 i18n 国际化 本地化 英语 意大利语 法语 德语 俄语 西班牙语 葡萄牙语 中文 简体中文 繁体中文 台湾繁体中文 日语 韩语 波兰语 中文 繁中文 zh-tw",
"command.useSystemLanguage": "使用系统语言",
"command.openH1Setting": "打开 H1 自动重命名设置",
"command.toggleGitignoredFilesVisibility": "切换 Gitignored 文件的可见性",
@@ -383,6 +383,8 @@
"inspector.title.properties": "属性",
"inspector.title.propertiesShortcut": "属性⌘⇧I",
"inspector.title.closePropertiesShortcut": "关闭属性⌘⇧I",
"inspector.title.collidingProperties": "冲突的属性",
"inspector.title.collidingPropertiesAria": "冲突的属性。打开原始编辑器。",
"inspector.empty.noNoteSelected": "未选择笔记",
"inspector.empty.noProperties": "这条笔记还没有属性",
"inspector.empty.initializeProperties": "初始化属性",

View File

@@ -9,7 +9,7 @@
"command.openSettings": "開啟設定",
"command.openSettings.keywords": "設定 偏好 配置",
"command.openLanguageSettings": "開啟語言設定",
"command.openLanguageSettings.keywords": "語言地區設定i18n、國際化、本地化英語、義大利文、法文、德文、俄文、西班牙文、葡萄牙文、簡體中文繁體中文、日文、韓文、中文 繁體中文zh-tw",
"command.openLanguageSettings.keywords": "語言 地區設定 i18n 国际化 本地化 英語 意大利語 法語 德語 俄語 西班牙葡萄牙語 中文 簡體中文 繁體中文 日語 韓語 波蘭語 中文 繁體中文 zh-tw",
"command.useSystemLanguage": "使用系統語言",
"command.openH1Setting": "開啟 H1 自動重新命名設定",
"command.toggleGitignoredFilesVisibility": "切換 Gitignored 檔案的顯示狀態",
@@ -383,6 +383,8 @@
"inspector.title.properties": "屬性",
"inspector.title.propertiesShortcut": "屬性⌘⇧I",
"inspector.title.closePropertiesShortcut": "關閉屬性⌘⇧I",
"inspector.title.collidingProperties": "衝突的屬性",
"inspector.title.collidingPropertiesAria": "衝突的屬性。開啟原始編輯器。",
"inspector.empty.noNoteSelected": "未選擇筆記",
"inspector.empty.noProperties": "這條筆記還沒有屬性",
"inspector.empty.initializeProperties": "初始化屬性",

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseFrontmatter, detectFrontmatterState } from './frontmatter'
import { parseFrontmatter, detectFrontmatterState, detectFrontmatterWarnings } from './frontmatter'
describe('parseFrontmatter', () => {
describe('numeric values', () => {
@@ -82,6 +82,13 @@ describe('parseFrontmatter', () => {
expect(fm['type']).toBe('Note')
expect(fm['status']).toBe('Active')
})
it('keeps the last value when frontmatter properties collide', () => {
const fm = parseFrontmatter('---\ntype: Note\nstatus: Active\nStatus: Evergreened\n---\n# Title')
expect(fm['status']).toBeUndefined()
expect(fm['Status']).toBe('Evergreened')
})
})
describe('detectFrontmatterState', () => {
@@ -121,3 +128,13 @@ describe('detectFrontmatterState', () => {
expect(detectFrontmatterState('---\n{broken: [yaml\n---\nBody')).toBe('invalid')
})
})
describe('detectFrontmatterWarnings', () => {
it('reports colliding frontmatter properties', () => {
const warnings = detectFrontmatterWarnings('---\ntype: Note\nstatus: Active\nStatus: Evergreened\n---\n# Title')
expect(warnings.collidingProperties).toEqual([
{ key: 'status', labels: ['status', 'Status'] },
])
})
})

View File

@@ -1,4 +1,5 @@
import type { FrontmatterValue } from '../components/Inspector'
import { canonicalSystemMetadataKey, normalizePropertyKey } from './systemMetadata'
export interface ParsedFrontmatter {
[key: string]: FrontmatterValue
@@ -10,6 +11,15 @@ type FrontmatterLine = string
type FrontmatterKey = string
type FrontmatterText = string
export interface FrontmatterCollisionWarning {
key: string
labels: string[]
}
export interface FrontmatterWarnings {
collidingProperties: FrontmatterCollisionWarning[]
}
const FRONTMATTER_CLOSE_DELIMITER = /(?:^|\r?\n)---(?:\r?\n|$)/
function unquote(s: FrontmatterText): FrontmatterText {
@@ -86,6 +96,45 @@ function parseKeyValueLine(line: FrontmatterLine): { key: FrontmatterKey, value:
}
}
function parseTopLevelKey(line: FrontmatterLine): FrontmatterKey | null {
if (line.trim() === '' || line.startsWith(' ') || line.startsWith('\t')) return null
return parseKeyValueLine(line)?.key ?? null
}
const FRONTMATTER_COLLISION_ALIASES: Record<string, string> = {
is_a: 'type',
archived: '_archived',
}
function frontmatterCollisionKey(key: FrontmatterKey): FrontmatterKey {
const normalized = normalizePropertyKey(canonicalSystemMetadataKey(key))
return FRONTMATTER_COLLISION_ALIASES[normalized] ?? normalized
}
function addCollisionCandidate(
groups: Map<FrontmatterKey, { labels: FrontmatterKey[]; count: number }>,
key: FrontmatterKey,
) {
const collisionKey = frontmatterCollisionKey(key)
const group = groups.get(collisionKey) ?? { labels: [], count: 0 }
group.count += 1
if (!group.labels.includes(key)) group.labels.push(key)
groups.set(collisionKey, group)
}
function assignFrontmatterValue(
result: ParsedFrontmatter,
collisionKeys: Map<FrontmatterKey, FrontmatterKey>,
key: FrontmatterKey,
value: FrontmatterValue,
) {
const collisionKey = frontmatterCollisionKey(key)
const previousKey = collisionKeys.get(collisionKey)
if (previousKey && previousKey !== key) delete result[previousKey]
collisionKeys.set(collisionKey, key)
result[key] = value
}
function parseFrontmatterValue(value: FrontmatterText): FrontmatterValue | undefined {
if (isBlockScalar(value)) return undefined
if (isInlineArrayLiteral(value)) return parseInlineArray(value)
@@ -94,11 +143,12 @@ function parseFrontmatterValue(value: FrontmatterText): FrontmatterValue | undef
function flushList(
result: ParsedFrontmatter,
collisionKeys: Map<FrontmatterKey, FrontmatterKey>,
currentKey: FrontmatterKey | null,
currentList: FrontmatterText[],
): FrontmatterText[] {
if (currentKey && currentList.length > 0) {
result[currentKey] = collapseList(currentList)
assignFrontmatterValue(result, collisionKeys, currentKey, collapseList(currentList))
}
return []
}
@@ -109,6 +159,7 @@ export function parseFrontmatter(content: MarkdownContent | null): ParsedFrontma
if (frontmatterBody === null) return {}
const result: ParsedFrontmatter = {}
const collisionKeys = new Map<FrontmatterKey, FrontmatterKey>()
let currentKey: FrontmatterKey | null = null
let currentList: FrontmatterText[] = []
@@ -119,7 +170,7 @@ export function parseFrontmatter(content: MarkdownContent | null): ParsedFrontma
continue
}
currentList = flushList(result, currentKey, currentList)
currentList = flushList(result, collisionKeys, currentKey, currentList)
const keyValue = parseKeyValueLine(line)
if (!keyValue) continue
@@ -127,10 +178,31 @@ export function parseFrontmatter(content: MarkdownContent | null): ParsedFrontma
const parsedValue = parseFrontmatterValue(keyValue.value)
if (parsedValue !== undefined) {
result[currentKey] = parsedValue
assignFrontmatterValue(result, collisionKeys, currentKey, parsedValue)
}
}
flushList(result, currentKey, currentList)
flushList(result, collisionKeys, currentKey, currentList)
return result
}
export function detectFrontmatterWarnings(content: MarkdownContent | null): FrontmatterWarnings {
const frontmatterBody = extractFrontmatterBody(content)
if (frontmatterBody === null) return { collidingProperties: [] }
const groups = new Map<FrontmatterKey, { labels: FrontmatterKey[]; count: number }>()
for (const line of frontmatterBody.split(/\r?\n/)) {
const key = parseTopLevelKey(line)
if (key) addCollisionCandidate(groups, key)
}
const collidingProperties = Array.from(groups.entries())
.filter(([, group]) => group.count > 1)
.map(([key, group]) => ({ key, labels: group.labels }))
return { collidingProperties }
}
export function hasFrontmatterWarnings(warnings: FrontmatterWarnings): boolean {
return warnings.collidingProperties.length > 0
}