feat: vault-aligned server v1 + UX polish
All checks were successful
CI / Windows build (push) Successful in 7m47s
All checks were successful
CI / Windows build (push) Successful in 7m47s
Redesign the optional FastAPI companion around vault files (manifest / PUT/GET/DELETE + OCR jobs) instead of legacy strokes_json notes. Wire a client Server settings panel for health/login. Polish shell UX: l10n for settings/home/board, sticky-board empty state, and a narrow-screen diagnostics FAB. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,7 +12,9 @@ All notes, documents, search, and OCR run on your device. No server is required
|
|||||||
- Infinite sticky board with `[[wikilinks]]` / backlinks
|
- Infinite sticky board with `[[wikilinks]]` / backlinks
|
||||||
- Full-text search over note titles, typed text, and OCR results
|
- Full-text search over note titles, typed text, and OCR results
|
||||||
- Always-on diagnostics + one-click diagnostic pack export (Settings)
|
- Always-on diagnostics + one-click diagnostic pack export (Settings)
|
||||||
|
- Optional self-hosted **BadNote Server** (`/api/v1`: vault assist + OCR jobs) — see [server/README.md](server/README.md)
|
||||||
- **Local OCR** — ONNX when bundled, else Windows WinRT
|
- **Local OCR** — ONNX when bundled, else Windows WinRT
|
||||||
|
- WebDAV vault sync (NAS)
|
||||||
|
|
||||||
## Build (Windows)
|
## Build (Windows)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'diagnostic_export.dart';
|
|||||||
import '../editor/canvas/input_diagnostics.dart';
|
import '../editor/canvas/input_diagnostics.dart';
|
||||||
import '../editor/input/diagnostic_logger.dart';
|
import '../editor/input/diagnostic_logger.dart';
|
||||||
import '../editor/input/pen_input_service.dart';
|
import '../editor/input/pen_input_service.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Shared diagnostics chrome: overlay readout + export action.
|
/// Shared diagnostics chrome: overlay readout + export action.
|
||||||
/// Mount on any document surface (note / PDF / PPT / board).
|
/// Mount on any document surface (note / PDF / PPT / board).
|
||||||
@@ -54,7 +55,7 @@ class DiagnosticChromeState extends State<DiagnosticChrome> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'诊断包已导出 (${result.bytes} bytes)\n路径已复制到剪贴板',
|
AppLocalizations.of(context).diagExported(result.bytes),
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
),
|
),
|
||||||
@@ -65,7 +66,9 @@ class DiagnosticChromeState extends State<DiagnosticChrome> {
|
|||||||
});
|
});
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('导出失败: $e')),
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context).diagExportFail('$e')),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _exporting = false);
|
if (mounted) setState(() => _exporting = false);
|
||||||
|
|||||||
@@ -199,5 +199,43 @@
|
|||||||
},
|
},
|
||||||
"syncAuto": "Sync automatically on launch",
|
"syncAuto": "Sync automatically on launch",
|
||||||
"syncCredentialsNote": "Credentials are stored locally in plain text. Use a dedicated app password.",
|
"syncCredentialsNote": "Credentials are stored locally in plain text. Use a dedicated app password.",
|
||||||
"syncNotConfigured": "Enter a server URL to enable sync."
|
"syncNotConfigured": "Enter a server URL to enable sync.",
|
||||||
|
"settingsDefaults": "Defaults",
|
||||||
|
"settingsAppearance": "Appearance",
|
||||||
|
"settingsAbout": "About",
|
||||||
|
"settingsDefaultTool": "Default tool",
|
||||||
|
"settingsDefaultColor": "Default color",
|
||||||
|
"settingsDefaultWidth": "Default stroke width",
|
||||||
|
"settingsPressureCurve": "Pressure curve",
|
||||||
|
"settingsClearConfirmBody": "This resets pen defaults and appearance. Notes and documents are not affected.",
|
||||||
|
"serverSection": "BadNote Server",
|
||||||
|
"serverUrl": "Server URL",
|
||||||
|
"serverUrlHint": "http://192.168.1.10:8080",
|
||||||
|
"serverUsername": "Username",
|
||||||
|
"serverPassword": "Password",
|
||||||
|
"serverSave": "Save & sign in",
|
||||||
|
"serverTest": "Test connection",
|
||||||
|
"serverTestOk": "Connected · API {version}",
|
||||||
|
"@serverTestOk": {
|
||||||
|
"placeholders": { "version": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"serverTestFail": "Connection failed: {error}",
|
||||||
|
"@serverTestFail": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"serverLoggedIn": "Signed in",
|
||||||
|
"serverHint": "Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.",
|
||||||
|
"boardEmptyTitle": "No sticky notes yet",
|
||||||
|
"boardEmptyBody": "Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.",
|
||||||
|
"relativeJustNow": "Just now",
|
||||||
|
"relativeMinutesAgo": "{n}m ago",
|
||||||
|
"@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } },
|
||||||
|
"relativeHoursAgo": "{n}h ago",
|
||||||
|
"@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } },
|
||||||
|
"relativeYesterday": "Yesterday",
|
||||||
|
"diagExported": "Diagnostic pack exported ({bytes} bytes)\nPath copied",
|
||||||
|
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
|
||||||
|
"diagExportFail": "Export failed: {error}",
|
||||||
|
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
|
||||||
|
"processingOcr": "Processing OCR…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -985,6 +985,174 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Enter a server URL to enable sync.'**
|
/// **'Enter a server URL to enable sync.'**
|
||||||
String get syncNotConfigured;
|
String get syncNotConfigured;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDefaults.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Defaults'**
|
||||||
|
String get settingsDefaults;
|
||||||
|
|
||||||
|
/// No description provided for @settingsAppearance.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Appearance'**
|
||||||
|
String get settingsAppearance;
|
||||||
|
|
||||||
|
/// No description provided for @settingsAbout.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'About'**
|
||||||
|
String get settingsAbout;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDefaultTool.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Default tool'**
|
||||||
|
String get settingsDefaultTool;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDefaultColor.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Default color'**
|
||||||
|
String get settingsDefaultColor;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDefaultWidth.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Default stroke width'**
|
||||||
|
String get settingsDefaultWidth;
|
||||||
|
|
||||||
|
/// No description provided for @settingsPressureCurve.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pressure curve'**
|
||||||
|
String get settingsPressureCurve;
|
||||||
|
|
||||||
|
/// No description provided for @settingsClearConfirmBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This resets pen defaults and appearance. Notes and documents are not affected.'**
|
||||||
|
String get settingsClearConfirmBody;
|
||||||
|
|
||||||
|
/// No description provided for @serverSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'BadNote Server'**
|
||||||
|
String get serverSection;
|
||||||
|
|
||||||
|
/// No description provided for @serverUrl.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Server URL'**
|
||||||
|
String get serverUrl;
|
||||||
|
|
||||||
|
/// No description provided for @serverUrlHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'http://192.168.1.10:8080'**
|
||||||
|
String get serverUrlHint;
|
||||||
|
|
||||||
|
/// No description provided for @serverUsername.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Username'**
|
||||||
|
String get serverUsername;
|
||||||
|
|
||||||
|
/// No description provided for @serverPassword.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Password'**
|
||||||
|
String get serverPassword;
|
||||||
|
|
||||||
|
/// No description provided for @serverSave.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save & sign in'**
|
||||||
|
String get serverSave;
|
||||||
|
|
||||||
|
/// No description provided for @serverTest.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Test connection'**
|
||||||
|
String get serverTest;
|
||||||
|
|
||||||
|
/// No description provided for @serverTestOk.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connected · API {version}'**
|
||||||
|
String serverTestOk(String version);
|
||||||
|
|
||||||
|
/// No description provided for @serverTestFail.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connection failed: {error}'**
|
||||||
|
String serverTestFail(String error);
|
||||||
|
|
||||||
|
/// No description provided for @serverLoggedIn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Signed in'**
|
||||||
|
String get serverLoggedIn;
|
||||||
|
|
||||||
|
/// No description provided for @serverHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.'**
|
||||||
|
String get serverHint;
|
||||||
|
|
||||||
|
/// No description provided for @boardEmptyTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No sticky notes yet'**
|
||||||
|
String get boardEmptyTitle;
|
||||||
|
|
||||||
|
/// No description provided for @boardEmptyBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.'**
|
||||||
|
String get boardEmptyBody;
|
||||||
|
|
||||||
|
/// No description provided for @relativeJustNow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Just now'**
|
||||||
|
String get relativeJustNow;
|
||||||
|
|
||||||
|
/// No description provided for @relativeMinutesAgo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{n}m ago'**
|
||||||
|
String relativeMinutesAgo(int n);
|
||||||
|
|
||||||
|
/// No description provided for @relativeHoursAgo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{n}h ago'**
|
||||||
|
String relativeHoursAgo(int n);
|
||||||
|
|
||||||
|
/// No description provided for @relativeYesterday.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Yesterday'**
|
||||||
|
String get relativeYesterday;
|
||||||
|
|
||||||
|
/// No description provided for @diagExported.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Diagnostic pack exported ({bytes} bytes)\nPath copied'**
|
||||||
|
String diagExported(int bytes);
|
||||||
|
|
||||||
|
/// No description provided for @diagExportFail.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Export failed: {error}'**
|
||||||
|
String diagExportFail(String error);
|
||||||
|
|
||||||
|
/// No description provided for @processingOcr.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Processing OCR…'**
|
||||||
|
String get processingOcr;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -489,4 +489,103 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get syncNotConfigured => 'Enter a server URL to enable sync.';
|
String get syncNotConfigured => 'Enter a server URL to enable sync.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaults => 'Defaults';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAppearance => 'Appearance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAbout => 'About';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultTool => 'Default tool';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultColor => 'Default color';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultWidth => 'Default stroke width';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsPressureCurve => 'Pressure curve';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsClearConfirmBody =>
|
||||||
|
'This resets pen defaults and appearance. Notes and documents are not affected.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverSection => 'BadNote Server';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUrl => 'Server URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUrlHint => 'http://192.168.1.10:8080';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUsername => 'Username';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverPassword => 'Password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverSave => 'Save & sign in';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverTest => 'Test connection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String serverTestOk(String version) {
|
||||||
|
return 'Connected · API $version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String serverTestFail(String error) {
|
||||||
|
return 'Connection failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverLoggedIn => 'Signed in';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverHint =>
|
||||||
|
'Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardEmptyTitle => 'No sticky notes yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardEmptyBody =>
|
||||||
|
'Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get relativeJustNow => 'Just now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String relativeMinutesAgo(int n) {
|
||||||
|
return '${n}m ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String relativeHoursAgo(int n) {
|
||||||
|
return '${n}h ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get relativeYesterday => 'Yesterday';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagExported(int bytes) {
|
||||||
|
return 'Diagnostic pack exported ($bytes bytes)\nPath copied';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagExportFail(String error) {
|
||||||
|
return 'Export failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get processingOcr => 'Processing OCR…';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,4 +484,100 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get syncNotConfigured => '请输入服务器地址以启用同步。';
|
String get syncNotConfigured => '请输入服务器地址以启用同步。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaults => '默认笔迹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAppearance => '外观';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAbout => '关于';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultTool => '默认工具';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultColor => '默认颜色';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultWidth => '默认线宽';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsPressureCurve => '压感曲线';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsClearConfirmBody => '将重置笔默认值与外观设置。笔记和文档不会受影响。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverSection => 'BadNote 服务器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUrl => '服务器地址';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUrlHint => 'http://192.168.1.10:8080';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUsername => '用户名';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverPassword => '密码';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverSave => '保存并登录';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverTest => '测试连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String serverTestOk(String version) {
|
||||||
|
return '连接成功 · API $version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String serverTestFail(String error) {
|
||||||
|
return '连接失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverLoggedIn => '已登录';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverHint => '可选。用于自托管 vault 协助同步与延迟 OCR;日常笔记仍完全离线。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardEmptyTitle => '还没有便利贴';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardEmptyBody => '点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get relativeJustNow => '刚刚';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String relativeMinutesAgo(int n) {
|
||||||
|
return '$n 分钟前';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String relativeHoursAgo(int n) {
|
||||||
|
return '$n 小时前';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get relativeYesterday => '昨天';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagExported(int bytes) {
|
||||||
|
return '诊断包已导出($bytes 字节)\n路径已复制';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagExportFail(String error) {
|
||||||
|
return '导出失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get processingOcr => '正在识别文字…';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,5 +169,43 @@
|
|||||||
},
|
},
|
||||||
"syncAuto": "启动时自动同步",
|
"syncAuto": "启动时自动同步",
|
||||||
"syncCredentialsNote": "凭据以明文保存在本地,建议使用专用的应用密码。",
|
"syncCredentialsNote": "凭据以明文保存在本地,建议使用专用的应用密码。",
|
||||||
"syncNotConfigured": "请输入服务器地址以启用同步。"
|
"syncNotConfigured": "请输入服务器地址以启用同步。",
|
||||||
|
"settingsDefaults": "默认笔迹",
|
||||||
|
"settingsAppearance": "外观",
|
||||||
|
"settingsAbout": "关于",
|
||||||
|
"settingsDefaultTool": "默认工具",
|
||||||
|
"settingsDefaultColor": "默认颜色",
|
||||||
|
"settingsDefaultWidth": "默认线宽",
|
||||||
|
"settingsPressureCurve": "压感曲线",
|
||||||
|
"settingsClearConfirmBody": "将重置笔默认值与外观设置。笔记和文档不会受影响。",
|
||||||
|
"serverSection": "BadNote 服务器",
|
||||||
|
"serverUrl": "服务器地址",
|
||||||
|
"serverUrlHint": "http://192.168.1.10:8080",
|
||||||
|
"serverUsername": "用户名",
|
||||||
|
"serverPassword": "密码",
|
||||||
|
"serverSave": "保存并登录",
|
||||||
|
"serverTest": "测试连接",
|
||||||
|
"serverTestOk": "连接成功 · API {version}",
|
||||||
|
"@serverTestOk": {
|
||||||
|
"placeholders": { "version": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"serverTestFail": "连接失败:{error}",
|
||||||
|
"@serverTestFail": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"serverLoggedIn": "已登录",
|
||||||
|
"serverHint": "可选。用于自托管 vault 协助同步与延迟 OCR;日常笔记仍完全离线。",
|
||||||
|
"boardEmptyTitle": "还没有便利贴",
|
||||||
|
"boardEmptyBody": "点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。",
|
||||||
|
"relativeJustNow": "刚刚",
|
||||||
|
"relativeMinutesAgo": "{n} 分钟前",
|
||||||
|
"@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } },
|
||||||
|
"relativeHoursAgo": "{n} 小时前",
|
||||||
|
"@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } },
|
||||||
|
"relativeYesterday": "昨天",
|
||||||
|
"diagExported": "诊断包已导出({bytes} 字节)\n路径已复制",
|
||||||
|
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
|
||||||
|
"diagExportFail": "导出失败:{error}",
|
||||||
|
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
|
||||||
|
"processingOcr": "正在识别文字…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,18 +115,29 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: body,
|
body: body,
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: Column(
|
||||||
selectedIndex: _index,
|
mainAxisSize: MainAxisSize.min,
|
||||||
onDestinationSelected: _select,
|
children: [
|
||||||
destinations: [
|
NavigationBar(
|
||||||
for (final d in destinations)
|
selectedIndex: _index,
|
||||||
NavigationDestination(
|
onDestinationSelected: _select,
|
||||||
icon: Icon(d.icon),
|
destinations: [
|
||||||
selectedIcon: Icon(d.selectedIcon),
|
for (final d in destinations)
|
||||||
label: d.label,
|
NavigationDestination(
|
||||||
),
|
icon: Icon(d.icon),
|
||||||
|
selectedIcon: Icon(d.selectedIcon),
|
||||||
|
label: d.label,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.small(
|
||||||
|
heroTag: 'diag_fab',
|
||||||
|
tooltip: AppLocalizations.of(context).diagnosticsSection,
|
||||||
|
onPressed: () => _diagKey.currentState?.toggle(),
|
||||||
|
child: const Icon(Icons.bug_report_outlined),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,14 +161,14 @@ Future<void> exportDiagnosticPack(BuildContext context) async {
|
|||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('诊断包: ${result.zipPath}'),
|
content: Text(AppLocalizations.of(context).diagExported(result.bytes)),
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('导出失败: $e')),
|
SnackBar(content: Text(AppLocalizations.of(context).diagExportFail('$e'))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ class _BoardScreenState extends ConsumerState<BoardScreen> {
|
|||||||
final selected = _selectedId != null ? _board.cardById(_selectedId!) : null;
|
final selected = _selectedId != null ? _board.cardById(_selectedId!) : null;
|
||||||
final backlinks =
|
final backlinks =
|
||||||
selected != null ? _board.backlinksOf(selected.id) : <String>{};
|
selected != null ? _board.backlinksOf(selected.id) : <String>{};
|
||||||
|
final isEmpty = _board.length == 0;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
@@ -153,7 +154,43 @@ class _BoardScreenState extends ConsumerState<BoardScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Row(
|
body: isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.sticky_note_2_outlined,
|
||||||
|
size: 72,
|
||||||
|
color: AppTokens.copper.withValues(alpha: 0.85),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
l.boardEmptyTitle,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l.boardEmptyBody,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: AppTokens.inkMuted,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _addCard,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text(l.boardAddCard),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: InteractiveViewer(
|
child: InteractiveViewer(
|
||||||
@@ -242,7 +279,7 @@ class _BoardScreenState extends ConsumerState<BoardScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'在便利贴正文里写 [[另一张卡片id]] 建立双链',
|
l.boardEmptyBody,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: AppTokens.inkMuted,
|
color: AppTokens.inkMuted,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -20,15 +20,16 @@ import '../editor/canvas/pen_slide_screen.dart';
|
|||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
// [M1] Relative date helper — no new package dependencies.
|
// Relative date helper — localized.
|
||||||
String _formatDate(DateTime d) {
|
String _formatDate(BuildContext context, DateTime d) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final diff = now.difference(d);
|
final diff = now.difference(d);
|
||||||
if (diff.inSeconds < 60) return 'Just now';
|
if (diff.inSeconds < 60) return l.relativeJustNow;
|
||||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
if (diff.inMinutes < 60) return l.relativeMinutesAgo(diff.inMinutes);
|
||||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
if (diff.inHours < 24) return l.relativeHoursAgo(diff.inHours);
|
||||||
if (diff.inDays == 1 || (diff.inDays == 0 && now.day != d.day)) {
|
if (diff.inDays == 1 || (diff.inDays == 0 && now.day != d.day)) {
|
||||||
return 'Yesterday';
|
return l.relativeYesterday;
|
||||||
}
|
}
|
||||||
return '${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
|
return '${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
@@ -411,7 +412,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final note = widget.note;
|
final note = widget.note;
|
||||||
// [M1] Use relative date helper
|
// [M1] Use relative date helper
|
||||||
final dateStr = _formatDate(note.updatedAt);
|
final dateStr = _formatDate(context, note.updatedAt);
|
||||||
|
|
||||||
final ocrStatusMap = ref.watch(ocrStatusProvider);
|
final ocrStatusMap = ref.watch(ocrStatusProvider);
|
||||||
final ocrStatus = ocrStatusMap[note.id] ?? OcrStatus.none;
|
final ocrStatus = ocrStatusMap[note.id] ?? OcrStatus.none;
|
||||||
@@ -584,7 +585,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final document = widget.document;
|
final document = widget.document;
|
||||||
// [M1] Use relative date helper
|
// [M1] Use relative date helper
|
||||||
final dateStr = _formatDate(document.updatedAt);
|
final dateStr = _formatDate(context, document.updatedAt);
|
||||||
final isPdf = document.docType == 'pdf';
|
final isPdf = document.docType == 'pdf';
|
||||||
|
|
||||||
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../models/pen_tool.dart';
|
|||||||
import '../models/pressure_curve.dart';
|
import '../models/pressure_curve.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../screens/app_shell.dart' show exportDiagnosticPack;
|
import '../screens/app_shell.dart' show exportDiagnosticPack;
|
||||||
|
import '../services/badnote_server_client.dart';
|
||||||
import '../services/vault_service.dart';
|
import '../services/vault_service.dart';
|
||||||
import '../services/webdav_sync_service.dart';
|
import '../services/webdav_sync_service.dart';
|
||||||
import '../utils/stroke_stabilizer.dart';
|
import '../utils/stroke_stabilizer.dart';
|
||||||
@@ -60,10 +61,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(AppLocalizations.of(ctx).clearSettingsTitle),
|
title: Text(AppLocalizations.of(ctx).clearSettingsTitle),
|
||||||
content: const Text(
|
content: Text(AppLocalizations.of(ctx).settingsClearConfirmBody),
|
||||||
'This will reset pen defaults and appearance settings. '
|
|
||||||
'Notes and documents are not affected.',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
@@ -107,15 +105,18 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
onTap: () => exportDiagnosticPack(context),
|
onTap: () => exportDiagnosticPack(context),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_SectionHeader(title: 'Defaults', icon: Icons.tune),
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).settingsDefaults,
|
||||||
|
icon: Icons.tune,
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Default Tool',
|
AppLocalizations.of(context).settingsDefaultTool,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
DropdownButtonFormField<PenTool>(
|
DropdownButtonFormField<PenTool>(
|
||||||
@@ -135,9 +136,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Default Color',
|
AppLocalizations.of(context).settingsDefaultColor,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
@@ -170,9 +171,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Default Stroke Width',
|
AppLocalizations.of(context).settingsDefaultWidth,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
Slider(
|
Slider(
|
||||||
value: settings.defaultStrokeWidth,
|
value: settings.defaultStrokeWidth,
|
||||||
@@ -183,9 +184,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
onChanged: settings.setDefaultStrokeWidth,
|
onChanged: settings.setDefaultStrokeWidth,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Pressure Curve',
|
AppLocalizations.of(context).settingsPressureCurve,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
DropdownButtonFormField<PressureCurveType>(
|
DropdownButtonFormField<PressureCurveType>(
|
||||||
@@ -232,15 +233,18 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_SectionHeader(title: 'Appearance', icon: Icons.palette),
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).settingsAppearance,
|
||||||
|
icon: Icons.palette,
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Theme Mode',
|
AppLocalizations.of(context).settingsAppearance,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
SegmentedButton<ThemeMode>(
|
SegmentedButton<ThemeMode>(
|
||||||
@@ -267,9 +271,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Color Scheme Seed',
|
AppLocalizations.of(context).seedColorDesc,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
@@ -302,7 +306,10 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_SectionHeader(title: 'Vault', icon: Icons.folder_special),
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).vaultSection,
|
||||||
|
icon: Icons.folder_special,
|
||||||
|
),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: _VaultSettings(),
|
child: _VaultSettings(),
|
||||||
@@ -317,7 +324,19 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
child: _SyncSettings(),
|
child: _SyncSettings(),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_SectionHeader(title: 'About', icon: Icons.info),
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).serverSection,
|
||||||
|
icon: Icons.dns_outlined,
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: _ServerSettings(),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).settingsAbout,
|
||||||
|
icon: Icons.info,
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -709,6 +728,166 @@ class _SyncSettingsState extends State<_SyncSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ServerSettings extends StatefulWidget {
|
||||||
|
const _ServerSettings();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ServerSettings> createState() => _ServerSettingsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerSettingsState extends State<_ServerSettings> {
|
||||||
|
final _urlCtrl = TextEditingController();
|
||||||
|
final _userCtrl = TextEditingController();
|
||||||
|
final _passCtrl = TextEditingController();
|
||||||
|
bool _busy = false;
|
||||||
|
bool _loggedIn = false;
|
||||||
|
String? _status;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_urlCtrl.dispose();
|
||||||
|
_userCtrl.dispose();
|
||||||
|
_passCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cfg = await BadNoteServerConfig.load(prefs);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_urlCtrl.text = cfg.baseUrl;
|
||||||
|
_userCtrl.text = cfg.username;
|
||||||
|
_passCtrl.text = cfg.password;
|
||||||
|
_loggedIn = cfg.isLoggedIn;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAndLogin() async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
var cfg = BadNoteServerConfig(
|
||||||
|
baseUrl: _urlCtrl.text.trim(),
|
||||||
|
username: _userCtrl.text.trim(),
|
||||||
|
password: _passCtrl.text,
|
||||||
|
);
|
||||||
|
final client = BadNoteServerClient(cfg);
|
||||||
|
cfg = await client.registerOrLogin(
|
||||||
|
username: cfg.username,
|
||||||
|
password: cfg.password,
|
||||||
|
);
|
||||||
|
await cfg.save(prefs);
|
||||||
|
client.close();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loggedIn = true;
|
||||||
|
_status = l.serverLoggedIn;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _status = l.serverTestFail('$e'));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _test() async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
final cfg = BadNoteServerConfig(baseUrl: _urlCtrl.text.trim());
|
||||||
|
final client = BadNoteServerClient(cfg);
|
||||||
|
final health = await client.health();
|
||||||
|
client.close();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_status = l.serverTestOk('${health['version'] ?? health['api']}');
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _status = l.serverTestFail('$e'));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l.serverHint,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _urlCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.serverUrl,
|
||||||
|
hintText: l.serverUrlHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _userCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.serverUsername,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _passCtrl,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.serverPassword,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _busy ? null : _saveAndLogin,
|
||||||
|
child: Text(l.serverSave),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _busy ? null : _test,
|
||||||
|
child: Text(l.serverTest),
|
||||||
|
),
|
||||||
|
if (_loggedIn)
|
||||||
|
Chip(
|
||||||
|
avatar: const Icon(Icons.check_circle, size: 16),
|
||||||
|
label: Text(l.serverLoggedIn),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_status != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(_status!, style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
class _SectionHeader extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|||||||
139
lib/services/badnote_server_client.dart
Normal file
139
lib/services/badnote_server_client.dart
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// HTTP client for the optional self-hosted BadNote Server (/api/v1).
|
||||||
|
// WebDAV remains the primary NAS sync path; this client covers health, auth,
|
||||||
|
// vault manifest assist, and OCR job submit/poll.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class BadNoteServerConfig {
|
||||||
|
const BadNoteServerConfig({
|
||||||
|
this.baseUrl = '',
|
||||||
|
this.username = '',
|
||||||
|
this.password = '',
|
||||||
|
this.token = '',
|
||||||
|
this.userId = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String baseUrl;
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
final String token;
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
bool get isConfigured => baseUrl.trim().isNotEmpty;
|
||||||
|
bool get isLoggedIn => token.isNotEmpty;
|
||||||
|
|
||||||
|
Uri? uri(String path) {
|
||||||
|
final base = baseUrl.trim().replaceAll(RegExp(r'/+$'), '');
|
||||||
|
if (base.isEmpty) return null;
|
||||||
|
final p = path.startsWith('/') ? path : '/$path';
|
||||||
|
return Uri.parse('$base$p');
|
||||||
|
}
|
||||||
|
|
||||||
|
BadNoteServerConfig copyWith({
|
||||||
|
String? baseUrl,
|
||||||
|
String? username,
|
||||||
|
String? password,
|
||||||
|
String? token,
|
||||||
|
String? userId,
|
||||||
|
}) =>
|
||||||
|
BadNoteServerConfig(
|
||||||
|
baseUrl: baseUrl ?? this.baseUrl,
|
||||||
|
username: username ?? this.username,
|
||||||
|
password: password ?? this.password,
|
||||||
|
token: token ?? this.token,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const _kBase = 'badnote.server.baseUrl';
|
||||||
|
static const _kUser = 'badnote.server.username';
|
||||||
|
static const _kPass = 'badnote.server.password';
|
||||||
|
static const _kToken = 'badnote.server.token';
|
||||||
|
static const _kUid = 'badnote.server.userId';
|
||||||
|
|
||||||
|
static Future<BadNoteServerConfig> load(SharedPreferences prefs) async {
|
||||||
|
return BadNoteServerConfig(
|
||||||
|
baseUrl: prefs.getString(_kBase) ?? '',
|
||||||
|
username: prefs.getString(_kUser) ?? '',
|
||||||
|
password: prefs.getString(_kPass) ?? '',
|
||||||
|
token: prefs.getString(_kToken) ?? '',
|
||||||
|
userId: prefs.getString(_kUid) ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save(SharedPreferences prefs) async {
|
||||||
|
await prefs.setString(_kBase, baseUrl);
|
||||||
|
await prefs.setString(_kUser, username);
|
||||||
|
await prefs.setString(_kPass, password);
|
||||||
|
await prefs.setString(_kToken, token);
|
||||||
|
await prefs.setString(_kUid, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BadNoteServerClient {
|
||||||
|
BadNoteServerClient(this.config, {http.Client? httpClient})
|
||||||
|
: _http = httpClient ?? http.Client();
|
||||||
|
|
||||||
|
BadNoteServerConfig config;
|
||||||
|
final http.Client _http;
|
||||||
|
|
||||||
|
Map<String, String> get _authHeaders => {
|
||||||
|
if (config.token.isNotEmpty) 'Authorization': 'Bearer ${config.token}',
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> health() async {
|
||||||
|
final uri = config.uri('/api/v1/health');
|
||||||
|
if (uri == null) throw StateError('server URL not set');
|
||||||
|
final res = await _http.get(uri).timeout(const Duration(seconds: 8));
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw StateError('health ${res.statusCode}: ${res.body}');
|
||||||
|
}
|
||||||
|
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BadNoteServerConfig> registerOrLogin({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
final registerUri = config.uri('/api/v1/auth/register');
|
||||||
|
if (registerUri == null) throw StateError('server URL not set');
|
||||||
|
var res = await _http.post(
|
||||||
|
registerUri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'username': username, 'password': password}),
|
||||||
|
);
|
||||||
|
if (res.statusCode == 409) {
|
||||||
|
final loginUri = config.uri('/api/v1/auth/login')!;
|
||||||
|
res = await _http.post(
|
||||||
|
loginUri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'username': username, 'password': password}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (res.statusCode != 200 && res.statusCode != 201) {
|
||||||
|
throw StateError('auth ${res.statusCode}: ${res.body}');
|
||||||
|
}
|
||||||
|
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
config = config.copyWith(
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
token: body['token'] as String? ?? '',
|
||||||
|
userId: body['user_id'] as String? ?? '',
|
||||||
|
);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> vaultManifest() async {
|
||||||
|
final uri = config.uri('/api/v1/vault/manifest');
|
||||||
|
if (uri == null) throw StateError('server URL not set');
|
||||||
|
final res = await _http.get(uri, headers: _authHeaders);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw StateError('manifest ${res.statusCode}: ${res.body}');
|
||||||
|
}
|
||||||
|
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() => _http.close();
|
||||||
|
}
|
||||||
@@ -1,24 +1,38 @@
|
|||||||
# BadNote Server (Optional)
|
# BadNote Server (Self-hosted companion)
|
||||||
|
|
||||||
This directory contains an **optional** Python/FastAPI backend. The BadNote desktop app does **not** depend on it.
|
Optional FastAPI backend for multi-device vault assist and deferred OCR.
|
||||||
|
The Flutter app stays local-first: notes work fully offline. This server is
|
||||||
|
for **your NAS / VPS**, not a hosted cloud product.
|
||||||
|
|
||||||
The Flutter client is local-first:
|
## Architecture (v2 / API v1)
|
||||||
|
|
||||||
- Notes and documents are stored in SQLite on device
|
```
|
||||||
- OCR runs locally via Windows built-in OCR
|
Client vault (files + *.badnote.json)
|
||||||
- Full-text search uses on-device FTS5
|
│
|
||||||
|
├─ WebDAV (NAS) ───────────── file sync (existing)
|
||||||
|
│
|
||||||
|
└─ BadNote Server /api/v1 ─── assist layer
|
||||||
|
├─ /auth JWT register/login
|
||||||
|
├─ /vault manifest + PUT/GET/DELETE (tombstones)
|
||||||
|
└─ /ocr upload ink PNG → job queue → EasyOCR worker
|
||||||
|
```
|
||||||
|
|
||||||
## Why this exists
|
**Source of truth = vault files**, not the legacy `notes.strokes_json` tables.
|
||||||
|
Legacy routers remain under `/api/legacy/*` (and old `/api/notes` paths) for
|
||||||
|
experiments only — new clients must use `/api/v1`.
|
||||||
|
|
||||||
This server was an early experiment for:
|
### Storage layout
|
||||||
|
|
||||||
- Multi-device note sync (push/pull)
|
```
|
||||||
- Server-side OCR with EasyOCR
|
data/
|
||||||
- JWT authentication
|
badnote_server.db # users
|
||||||
|
.jwt_secret # if BADNOTE_JWT_SECRET unset
|
||||||
|
vaults/<user_id>/files/ # mirrors client vault
|
||||||
|
storage/ocr_blobs/… # uploaded ink rasters
|
||||||
|
queue/{pending,processing,done,failed}/
|
||||||
|
```
|
||||||
|
|
||||||
These features are **not wired into the current client**. The client previously had incomplete sync/OCR scaffolding that has been removed in favor of local processing.
|
## Run
|
||||||
|
|
||||||
## Running (if you want to experiment)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
cd server
|
||||||
@@ -28,25 +42,43 @@ pip install -r requirements.txt
|
|||||||
uvicorn badnote_server.main:app --host 0.0.0.0 --port 8080
|
uvicorn badnote_server.main:app --host 0.0.0.0 --port 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
API docs: http://localhost:8080/docs
|
- Health: `GET /api/v1/health`
|
||||||
|
- OpenAPI: http://localhost:8080/docs
|
||||||
|
|
||||||
The OCR worker has heavy extra dependencies (EasyOCR + torch). Install them only
|
### OCR worker (optional, heavy)
|
||||||
if you want to run it:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements-ocr.txt
|
pip install -r requirements-ocr.txt
|
||||||
python -m badnote_server.ocr.worker
|
python -m badnote_server.ocr.worker
|
||||||
```
|
```
|
||||||
|
|
||||||
### Security notes
|
### Security
|
||||||
|
|
||||||
- Set `BADNOTE_JWT_SECRET` in production. If unset, a secret is generated once
|
- Set `BADNOTE_JWT_SECRET` in production.
|
||||||
and persisted to `<data>/.jwt_secret` so tokens survive restarts.
|
- Restrict CORS with `BADNOTE_CORS_ORIGINS`.
|
||||||
- Restrict origins with `BADNOTE_CORS_ORIGINS` (comma-separated). The default is
|
- Prefer HTTPS reverse proxy (Caddy/Nginx) in front of uvicorn.
|
||||||
permissive (`*`, without credentials) for local development.
|
|
||||||
|
### Env
|
||||||
|
|
||||||
|
| Variable | Default | Meaning |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `BADNOTE_HOST` / `PORT` | `0.0.0.0` / `8080` | Bind |
|
||||||
|
| `BADNOTE_DB_PATH` | `./data/badnote_server.db` | Users DB |
|
||||||
|
| `BADNOTE_VAULT_PATH` | `./data/vaults` | Per-user vault trees |
|
||||||
|
| `BADNOTE_STORAGE_PATH` | `./data/storage` | Blobs |
|
||||||
|
| `BADNOTE_QUEUE_PATH` | `./data/queue` | OCR jobs |
|
||||||
|
| `BADNOTE_JWT_SECRET` | persisted file | Signing key |
|
||||||
|
| `BADNOTE_CORS_ORIGINS` | `*` | Allowed origins |
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
In BadNote → Settings → **BadNote Server**, set base URL (e.g.
|
||||||
|
`http://192.168.1.10:8080`), register/login, then **Test connection**.
|
||||||
|
Vault file sync via the API is additive to WebDAV; OCR upload is opt-in when
|
||||||
|
online/charging (future client job).
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- Kept for reference and future optional sync work
|
- **v1 vault + health + OCR enqueue**: implemented
|
||||||
- Not part of the primary development path
|
- **Wiki / semantic search**: stubbed for later (`501` reserved)
|
||||||
- No guarantee of API compatibility with future client versions
|
- Legacy notes push/pull: deprecated, not used by current Flutter app
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ class Settings:
|
|||||||
jwt_secret: str = _resolve_jwt_secret()
|
jwt_secret: str = _resolve_jwt_secret()
|
||||||
jwt_expiry_hours: int = int(os.environ.get("BADNOTE_JWT_EXPIRY_HOURS", "720"))
|
jwt_expiry_hours: int = int(os.environ.get("BADNOTE_JWT_EXPIRY_HOURS", "720"))
|
||||||
cors_origins: list[str] = _resolve_cors_origins()
|
cors_origins: list[str] = _resolve_cors_origins()
|
||||||
|
# Per-user vault trees (notebook folders + sidecars), independent of legacy
|
||||||
|
# notes.strokes_json storage.
|
||||||
|
vault_path: str = os.environ.get("BADNOTE_VAULT_PATH", "./data/vaults")
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
"""BadNote FastAPI server — main application."""
|
"""BadNote FastAPI server — main application.
|
||||||
|
|
||||||
|
Architecture (v1):
|
||||||
|
- Vault files are the source of truth (same layout as the Flutter vault).
|
||||||
|
- ``/api/v1/vault/*`` assists multi-device sync (manifest + PUT/GET/DELETE).
|
||||||
|
- ``/api/v1/ocr/*`` accepts ink rasters for deferred server OCR.
|
||||||
|
- Legacy ``/api/notes`` etc. remain mounted under ``/api/legacy/*`` for
|
||||||
|
compatibility with old experiments; new clients must not use them.
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -13,12 +21,15 @@ from .routers.notes_router import router as notes_router
|
|||||||
from .routers.documents_router import router as documents_router
|
from .routers.documents_router import router as documents_router
|
||||||
from .routers.ocr_router import router as ocr_router
|
from .routers.ocr_router import router as ocr_router
|
||||||
from .routers.sync_router import router as sync_router
|
from .routers.sync_router import router as sync_router
|
||||||
|
from .routers.v1_vault_router import router as v1_vault_router
|
||||||
|
from .routers.v1_ocr_router import router as v1_ocr_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Startup: create directories and init DB. Shutdown: close DB."""
|
"""Startup: create directories and init DB. Shutdown: close DB."""
|
||||||
os.makedirs(settings.storage_path, exist_ok=True)
|
os.makedirs(settings.storage_path, exist_ok=True)
|
||||||
|
os.makedirs(settings.vault_path, exist_ok=True)
|
||||||
for subdir in ("pending", "processing", "done", "failed"):
|
for subdir in ("pending", "processing", "done", "failed"):
|
||||||
os.makedirs(os.path.join(settings.queue_path, subdir), exist_ok=True)
|
os.makedirs(os.path.join(settings.queue_path, subdir), exist_ok=True)
|
||||||
await init_db()
|
await init_db()
|
||||||
@@ -26,12 +37,17 @@ async def lifespan(app: FastAPI):
|
|||||||
await close_db()
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="BadNote Server", version="1.0.0", lifespan=lifespan)
|
app = FastAPI(
|
||||||
|
title="BadNote Server",
|
||||||
|
version="2.0.0",
|
||||||
|
description=(
|
||||||
|
"Self-hosted companion for BadNote. "
|
||||||
|
"Primary API is /api/v1 (vault + OCR). "
|
||||||
|
"Legacy notes CRUD lives under /api/legacy."
|
||||||
|
),
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
# Authentication is Bearer-token based, so cookies/credentials are not needed.
|
|
||||||
# `allow_origins=["*"]` together with `allow_credentials=True` is an invalid and
|
|
||||||
# insecure combination, so we keep credentials disabled. Set BADNOTE_CORS_ORIGINS
|
|
||||||
# (comma-separated) to lock the API down to specific front-end origins.
|
|
||||||
_cors_origins = settings.cors_origins or ["*"]
|
_cors_origins = settings.cors_origins or ["*"]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -42,14 +58,34 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Auth (shared by v1 + legacy) ---
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(notes_router, prefix="/api/notes", tags=["notes"])
|
app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth-v1"])
|
||||||
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
|
|
||||||
app.include_router(ocr_router, prefix="/api/ocr", tags=["ocr"])
|
# --- Primary v1 API ---
|
||||||
app.include_router(sync_router, prefix="/api/sync", tags=["sync"])
|
app.include_router(v1_vault_router, prefix="/api/v1/vault", tags=["vault-v1"])
|
||||||
|
app.include_router(v1_ocr_router, prefix="/api/v1/ocr", tags=["ocr-v1"])
|
||||||
|
|
||||||
|
# --- Legacy (deprecated) ---
|
||||||
|
app.include_router(notes_router, prefix="/api/legacy/notes", tags=["legacy-notes"])
|
||||||
|
app.include_router(documents_router, prefix="/api/legacy/documents", tags=["legacy-documents"])
|
||||||
|
app.include_router(ocr_router, prefix="/api/legacy/ocr", tags=["legacy-ocr"])
|
||||||
|
app.include_router(sync_router, prefix="/api/legacy/sync", tags=["legacy-sync"])
|
||||||
|
# Keep old paths temporarily so existing bookmarks to /docs experiments still work,
|
||||||
|
# but they are the same legacy routers.
|
||||||
|
app.include_router(notes_router, prefix="/api/notes", tags=["legacy-notes"], include_in_schema=False)
|
||||||
|
app.include_router(documents_router, prefix="/api/documents", tags=["legacy-documents"], include_in_schema=False)
|
||||||
|
app.include_router(ocr_router, prefix="/api/ocr", tags=["legacy-ocr"], include_in_schema=False)
|
||||||
|
app.include_router(sync_router, prefix="/api/sync", tags=["legacy-sync"], include_in_schema=False)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/ping")
|
@app.get("/api/ping")
|
||||||
async def ping() -> dict:
|
@app.get("/api/v1/health")
|
||||||
"""Health check endpoint."""
|
async def health() -> dict:
|
||||||
return {"status": "ok"}
|
"""Liveness probe for clients and reverse proxies."""
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"api": "v1",
|
||||||
|
"features": ["vault", "ocr", "auth"],
|
||||||
|
}
|
||||||
|
|||||||
74
server/badnote_server/routers/v1_ocr_router.py
Normal file
74
server/badnote_server/routers/v1_ocr_router.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""v1 OCR job API — upload ink raster, poll status, fetch text."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
|
||||||
|
from ..auth import get_current_user
|
||||||
|
from ..config import settings
|
||||||
|
from ..ocr import queue as ocr_queue
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs", status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def create_ocr_job(
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
source_path: str = Form(""),
|
||||||
|
page_index: int = Form(0),
|
||||||
|
user_id: str = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Enqueue an OCR job from an uploaded PNG/JPEG of handwriting."""
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
blob_dir = os.path.join(settings.storage_path, "ocr_blobs", user_id)
|
||||||
|
os.makedirs(blob_dir, exist_ok=True)
|
||||||
|
ext = os.path.splitext(image.filename or "ink.png")[1] or ".png"
|
||||||
|
blob_path = os.path.join(blob_dir, f"{job_id}{ext}")
|
||||||
|
data = await image.read()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty image")
|
||||||
|
with open(blob_path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
ocr_queue.enqueue(
|
||||||
|
{
|
||||||
|
"id": job_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"source_path": source_path,
|
||||||
|
"note_id": source_path or job_id,
|
||||||
|
"page_index": page_index,
|
||||||
|
"image_path": blob_path,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": "pending",
|
||||||
|
"source_path": source_path,
|
||||||
|
"page_index": page_index,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}")
|
||||||
|
async def get_ocr_job(
|
||||||
|
job_id: str,
|
||||||
|
user_id: str = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
job = ocr_queue.get_status(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="job not found")
|
||||||
|
if job.get("user_id") not in (None, user_id):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="forbidden")
|
||||||
|
return {
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": job.get("status"),
|
||||||
|
"result": job.get("result_text") or job.get("result") or job.get("text"),
|
||||||
|
"error": job.get("error_message") or job.get("error"),
|
||||||
|
"source_path": job.get("source_path"),
|
||||||
|
"page_index": job.get("page_index"),
|
||||||
|
}
|
||||||
63
server/badnote_server/routers/v1_vault_router.py
Normal file
63
server/badnote_server/routers/v1_vault_router.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""v1 vault sync-assist API — vault files are the source of truth."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from ..auth import get_current_user
|
||||||
|
from .. import vault_store
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/manifest")
|
||||||
|
async def get_manifest(user_id: str = Depends(get_current_user)) -> dict:
|
||||||
|
"""List all files in the user's vault with size/mtime/sha256."""
|
||||||
|
return vault_store.build_manifest(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_path:path}")
|
||||||
|
async def download_file(
|
||||||
|
file_path: str,
|
||||||
|
user_id: str = Depends(get_current_user),
|
||||||
|
) -> Response:
|
||||||
|
try:
|
||||||
|
data = vault_store.read_file(user_id, file_path)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"X-Vault-Path": file_path},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/files/{file_path:path}")
|
||||||
|
async def upload_file(
|
||||||
|
file_path: str,
|
||||||
|
upload: UploadFile = File(...),
|
||||||
|
user_id: str = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
data = await upload.read()
|
||||||
|
entry = vault_store.write_file(user_id, file_path, data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
return entry.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/files/{file_path:path}")
|
||||||
|
async def remove_file(
|
||||||
|
file_path: str,
|
||||||
|
user_id: str = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
vault_store.delete_file(user_id, file_path, tombstone=True)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||||
|
return {"deleted": file_path, "tombstone": True}
|
||||||
124
server/badnote_server/vault_store.py
Normal file
124
server/badnote_server/vault_store.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""Per-user vault file store — mirrors the client vault layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VaultFileEntry:
|
||||||
|
path: str
|
||||||
|
size: int
|
||||||
|
mtime: float
|
||||||
|
sha256: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"path": self.path,
|
||||||
|
"size": self.size,
|
||||||
|
"mtime": self.mtime,
|
||||||
|
"sha256": self.sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def vault_root(user_id: str) -> Path:
|
||||||
|
root = Path(settings.vault_path) / user_id / "files"
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_relpath(rel: str) -> str:
|
||||||
|
"""Normalize and reject path escape attempts."""
|
||||||
|
cleaned = rel.replace("\\", "/").lstrip("/")
|
||||||
|
if not cleaned or cleaned.startswith("..") or "/../" in f"/{cleaned}/":
|
||||||
|
raise ValueError(f"invalid vault path: {rel!r}")
|
||||||
|
parts = Path(cleaned).parts
|
||||||
|
if ".." in parts:
|
||||||
|
raise ValueError(f"invalid vault path: {rel!r}")
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_path(user_id: str, rel: str) -> Path:
|
||||||
|
rel_n = _safe_relpath(rel)
|
||||||
|
full = (vault_root(user_id) / rel_n).resolve()
|
||||||
|
root = vault_root(user_id).resolve()
|
||||||
|
if not str(full).startswith(str(root) + os.sep) and full != root:
|
||||||
|
raise ValueError(f"path escapes vault: {rel!r}")
|
||||||
|
return full
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def build_manifest(user_id: str) -> dict:
|
||||||
|
root = vault_root(user_id)
|
||||||
|
entries: list[VaultFileEntry] = []
|
||||||
|
for path in sorted(root.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
if path.name.startswith("."):
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(root).as_posix()
|
||||||
|
st = path.stat()
|
||||||
|
entries.append(
|
||||||
|
VaultFileEntry(
|
||||||
|
path=rel,
|
||||||
|
size=st.st_size,
|
||||||
|
mtime=st.st_mtime,
|
||||||
|
sha256=_sha256_file(path),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"generated_at": time.time(),
|
||||||
|
"files": [e.to_dict() for e in entries],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_file(user_id: str, rel: str, data: bytes) -> VaultFileEntry:
|
||||||
|
path = resolve_path(user_id, rel)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_bytes(data)
|
||||||
|
tmp.replace(path)
|
||||||
|
st = path.stat()
|
||||||
|
return VaultFileEntry(
|
||||||
|
path=_safe_relpath(rel),
|
||||||
|
size=st.st_size,
|
||||||
|
mtime=st.st_mtime,
|
||||||
|
sha256=_sha256_file(path),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_file(user_id: str, rel: str) -> bytes:
|
||||||
|
path = resolve_path(user_id, rel)
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(rel)
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_file(user_id: str, rel: str, *, tombstone: bool = True) -> None:
|
||||||
|
path = resolve_path(user_id, rel)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(rel)
|
||||||
|
if tombstone:
|
||||||
|
tomb = path.parent / f".tombstone-{path.name}"
|
||||||
|
meta = {
|
||||||
|
"path": _safe_relpath(rel),
|
||||||
|
"deleted_at": time.time(),
|
||||||
|
"sha256": _sha256_file(path) if path.is_file() else None,
|
||||||
|
}
|
||||||
|
tomb.write_text(json.dumps(meta), encoding="utf-8")
|
||||||
|
path.unlink()
|
||||||
@@ -14,6 +14,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|||||||
|
|
||||||
os.environ["BADNOTE_STORAGE_PATH"] = "/tmp/badnote_test_storage"
|
os.environ["BADNOTE_STORAGE_PATH"] = "/tmp/badnote_test_storage"
|
||||||
os.environ["BADNOTE_QUEUE_PATH"] = "/tmp/badnote_test_queue"
|
os.environ["BADNOTE_QUEUE_PATH"] = "/tmp/badnote_test_queue"
|
||||||
|
os.environ["BADNOTE_VAULT_PATH"] = "/tmp/badnote_test_vaults"
|
||||||
os.environ["BADNOTE_JWT_SECRET"] = "test-secret-key-for-testing-only"
|
os.environ["BADNOTE_JWT_SECRET"] = "test-secret-key-for-testing-only"
|
||||||
|
|
||||||
from badnote_server.config import settings # noqa: E402
|
from badnote_server.config import settings # noqa: E402
|
||||||
@@ -26,10 +27,13 @@ async def setup_db(tmp_path):
|
|||||||
"""Fresh DB and clean queue for each test."""
|
"""Fresh DB and clean queue for each test."""
|
||||||
db_path = str(tmp_path / "test.db")
|
db_path = str(tmp_path / "test.db")
|
||||||
settings.db_path = db_path
|
settings.db_path = db_path
|
||||||
|
settings.vault_path = str(tmp_path / "vaults")
|
||||||
# Clean the queue directory before each test
|
# Clean the queue directory before each test
|
||||||
queue_path = settings.queue_path
|
queue_path = settings.queue_path
|
||||||
if os.path.exists(queue_path):
|
if os.path.exists(queue_path):
|
||||||
shutil.rmtree(queue_path)
|
shutil.rmtree(queue_path)
|
||||||
|
if os.path.exists(settings.vault_path):
|
||||||
|
shutil.rmtree(settings.vault_path)
|
||||||
await init_db()
|
await init_db()
|
||||||
yield
|
yield
|
||||||
await close_db()
|
await close_db()
|
||||||
|
|||||||
88
server/tests/test_vault_v1.py
Normal file
88
server/tests/test_vault_v1.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Tests for v1 vault API (async, uses shared conftest)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
async def _token(client) -> str:
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"username": "vault_user", "password": "password123"},
|
||||||
|
)
|
||||||
|
if r.status_code == 409:
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "vault_user", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert r.status_code in (200, 201), r.text
|
||||||
|
return r.json()["token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health(client):
|
||||||
|
r = await client.get("/api/v1/health")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["api"] == "v1"
|
||||||
|
assert "vault" in body["features"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vault_roundtrip(client, tmp_path, monkeypatch):
|
||||||
|
from badnote_server.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "vault_path", str(tmp_path / "vaults"))
|
||||||
|
|
||||||
|
token = await _token(client)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
files = {"upload": ("hello.txt", b"hello vault", "text/plain")}
|
||||||
|
r = await client.put(
|
||||||
|
"/api/v1/vault/files/NotebookA/hello.txt",
|
||||||
|
headers=headers,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["path"] == "NotebookA/hello.txt"
|
||||||
|
assert r.json()["size"] == 11
|
||||||
|
|
||||||
|
r = await client.get("/api/v1/vault/manifest", headers=headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
paths = [f["path"] for f in r.json()["files"]]
|
||||||
|
assert "NotebookA/hello.txt" in paths
|
||||||
|
|
||||||
|
r = await client.get(
|
||||||
|
"/api/v1/vault/files/NotebookA/hello.txt",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.content == b"hello vault"
|
||||||
|
|
||||||
|
r = await client.delete(
|
||||||
|
"/api/v1/vault/files/NotebookA/hello.txt",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = await client.get(
|
||||||
|
"/api/v1/vault/files/NotebookA/hello.txt",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_path_escape_rejected(client, tmp_path, monkeypatch):
|
||||||
|
from badnote_server.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "vault_path", str(tmp_path / "vaults"))
|
||||||
|
|
||||||
|
token = await _token(client)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
files = {"upload": ("x", b"nope", "application/octet-stream")}
|
||||||
|
r = await client.put(
|
||||||
|
"/api/v1/vault/files/../../etc/passwd",
|
||||||
|
headers=headers,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
assert r.status_code in (400, 404)
|
||||||
Reference in New Issue
Block a user