1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-04-18 19:42:23 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Laurent Cozic 1e4c3d81ba update 2026-04-15 10:30:21 +01:00
72 changed files with 1550 additions and 7368 deletions
+16 -18
View File
@@ -4,11 +4,9 @@ reviews:
high_level_summary: false
estimate_code_review_effort: false
poem: false
review_status: false
review_details: false
auto_review:
enabled: true
drafts: true
drafts: false
ignore_usernames:
- "renovate[bot]"
auto_apply_labels: true
@@ -88,21 +86,21 @@ reviews:
- label: "windows"
instructions: "Apply when the PR is mainly about changes specific to Windows"
# pre_merge_checks:
# description:
# mode: "warning"
# custom_checks:
# - name: "PR Description Must Follow Guidelines"
# mode: "error"
# instructions: |
# Fail if the pull request description does not include clear sections for:
# - Problem or user-impact description
# - A high-level Solution explanation
# - Any Test Plan or verification steps
#
# The description should align with our PR guidelines
# at https://github.com/joplin/gsoc/blob/master/pull_request_guidelines.md
# and should not just restate the diff or implementation details.
pre_merge_checks:
description:
mode: "warning"
custom_checks:
- name: "PR Description Must Follow Guidelines"
mode: "error"
instructions: |
Fail if the pull request description does not include clear sections for:
- Problem or user-impact description
- A high-level Solution explanation
- Any Test Plan or verification steps
The description should align with our PR guidelines
at https://github.com/joplin/gsoc/blob/master/pull_request_guidelines.md
and should not just restate the diff or implementation details.
knowledge_base:
code_guidelines:
enabled: true
@@ -1,48 +0,0 @@
name: Delete CodeRabbit PR Comments
on:
issue_comment:
types: [created]
pull_request_review:
types: [submitted]
jobs:
delete-issue-comment:
if: >
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(github.event.comment.user.login == 'coderabbitai' || github.event.comment.user.login == 'coderabbitai[bot]')
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Delete CodeRabbit comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api -X DELETE "${{ github.event.comment.url }}"
hide-review-summary:
if: >
github.event_name == 'pull_request_review' &&
(github.event.review.user.login == 'coderabbitai' || github.event.review.user.login == 'coderabbitai[bot]')
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Minimize CodeRabbit review comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_ID: ${{ github.event.review.node_id }}
run: |
# Hide the review summary using GraphQL minimizeComment mutation
gh api graphql -f query='
mutation {
minimizeComment(input: {subjectId: "'"${NODE_ID}"'", classifier: OFF_TOPIC}) {
minimizedComment {
isMinimized
}
}
}
'
+1 -1
View File
@@ -12,7 +12,7 @@
- Avoid duplicating code in tests; when testing the same logic with different inputs, use `test.each` or shared helpers instead of repeating similar test blocks.
- Do not make white space changes - do not add unnecessary new lines, or spaces to existing code, or wrap existing code.
- If you add a new TypeScript file, run `yarn updateIgnored` from the root.
- When an unknown word is detected by cSpell, handle it as per the specification in `readme/dev/spellcheck.md`
- When an unknown word is detected by cSpell, handle is as per the specification in `readme/dev/spellcheck.md`
- To compile TypeScript, use `yarn tsc`. To type-check without emitting files, use `yarn tsc --noEmit`.
## Full Documentation
@@ -59,7 +59,6 @@ export default class ElectronAppWrapper {
private secondaryWindows_: Map<SecondaryWindowId, SecondaryWindowData> = new Map();
private willQuitApp_ = false;
private enableUnresponsiveCheck_ = true;
private tray_: Tray = null;
private buildDir_: string = null;
private rendererProcessQuitReply_: RendererProcessQuitReply = null;
@@ -308,8 +307,6 @@ export default class ElectronAppWrapper {
let unresponsiveTimeout: ReturnType<typeof setTimeout>|null = null;
this.win_.webContents.on('unresponsive', () => {
if (!this.enableUnresponsiveCheck_) return;
// Don't show the "unresponsive" dialog immediately -- the "unresponsive" event
// can be fired when showing a dialog or modal (e.g. the update dialog).
//
@@ -899,10 +896,6 @@ export default class ElectronAppWrapper {
return this.customProtocolHandlers_.pluginContent;
}
public setEnableUnresponsiveCheck(enabled: boolean) {
this.enableUnresponsiveCheck_ = enabled;
}
private async fixLinuxAccessibility_() {
if (this.electronApp().accessibilitySupportEnabled) return;
+5
View File
@@ -48,6 +48,7 @@ import ShareService from '@joplin/lib/services/share/ShareService';
import checkForUpdates from './checkForUpdates';
import { AppState } from './app.reducer';
import syncDebugLog from '@joplin/lib/services/synchronizer/syncDebugLog';
import { completePendingAuthentication } from '@joplin/lib/services/joplinCloudUtils';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import path = require('path');
import { afterDefaultPluginsLoaded, loadAndRunDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
@@ -607,6 +608,10 @@ class Application extends BaseApplication {
}
});
addTask('app/complete pending Joplin Cloud auth', async () => {
await completePendingAuthentication();
});
addTask('app/start maintenance tasks', () => {
// Always disable on Mac for now - and disable too for the few apps that may have the flag enabled.
// At present, it only seems to work on Windows.
-4
View File
@@ -483,10 +483,6 @@ export class Bridge {
setLocale(locale);
}
public setEnableUnresponsiveCheck(enabled: boolean) {
this.electronWrapper_.setEnableUnresponsiveCheck(enabled);
}
public get Menu() {
return Menu;
}
@@ -6,7 +6,7 @@ import { clipboard } from 'electron';
import Button, { ButtonLevel } from './Button/Button';
import { uuidgen } from '@joplin/lib/uuid';
import { Dispatch } from 'redux';
import { reducer, defaultState, generateApplicationConfirmUrl, checkIfLoginWasSuccessful } from '@joplin/lib/services/joplinCloudUtils';
import { reducer, defaultState, generateApplicationConfirmUrl, checkIfLoginWasSuccessful, saveApplicationAuthId } from '@joplin/lib/services/joplinCloudUtils';
import { AppState } from '../app.reducer';
import Logger from '@joplin/utils/Logger';
import { reg } from '@joplin/lib/registry';
@@ -57,6 +57,7 @@ const JoplinCloudScreenComponent = (props: Props) => {
if (state.next === 'LINK_USED') {
dispatch({ type: 'LINK_USED' });
}
saveApplicationAuthId(applicationAuthId);
periodicallyCheckForCredentials();
};
-5
View File
@@ -200,11 +200,6 @@ function menuItemSetEnabled(id: string, enabled: boolean) {
const menu = Menu.getApplicationMenu();
const menuItem = menu.getMenuItemById(id);
if (!menuItem) return;
// Don't disable menu items that have a role (e.g. copy, paste, cut,
// selectAll). Since Electron 40, disabling a role-based menu item also
// prevents the native role behaviour, which breaks clipboard operations
// in non-editor input fields such as the Settings screen.
if (!enabled && menuItem.role) return;
menuItem.enabled = enabled;
}
@@ -90,19 +90,6 @@ export function resourcesStatus(resourceInfos: any) {
return joplinRendererUtils.resourceStatusName(lowestIndex);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const clipboardImageToResource = async (image: any, mime: string) => {
const fileExt = mimeUtils.toFileExtension(mime);
const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`;
await shim.writeImageToFile(image, mime, filePath);
try {
const md = await commandAttachFileToBody('', [filePath]);
return md;
} finally {
await shim.fsDriver().remove(filePath);
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export async function getResourcesFromPasteEvent(event: any) {
const output = [];
@@ -117,22 +104,19 @@ export async function getResourcesFromPasteEvent(event: any) {
continue;
}
if (event) event.preventDefault();
const md = await clipboardImageToResource(clipboard.readImage(), format);
const image = clipboard.readImage();
const fileExt = mimeUtils.toFileExtension(format);
const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`;
await shim.writeImageToFile(image, format, filePath);
const md = await commandAttachFileToBody('', [filePath]);
await shim.fsDriver().remove(filePath);
if (md) output.push(md);
}
}
// Some applications (e.g. macshot) copy images to the clipboard without
// an image/* format, but clipboard.readImage() can still read them.
if (!output.length) {
const image = clipboard.readImage();
if (!image.isEmpty()) {
if (event) event.preventDefault();
const md = await clipboardImageToResource(image, 'image/png');
if (md) output.push(md);
}
}
return output;
}
+43 -40
View File
@@ -1,9 +1,9 @@
import * as React from 'react';
import { useCallback } from 'react';
import { StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton, StyledRoot } from './styles';
import { ButtonLevel } from '../Button/Button';
import CommandService from '@joplin/lib/services/CommandService';
import Synchronizer from '@joplin/lib/Synchronizer';
import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale';
import { AppState } from '../../app.reducer';
import { StateDecryptionWorker, StateResourceFetcher } from '@joplin/lib/reducer';
@@ -11,6 +11,8 @@ import { connect } from 'react-redux';
import { themeStyle } from '@joplin/lib/theme';
import { Dispatch } from 'redux';
import FolderAndTagList from './FolderAndTagList';
import Setting from '@joplin/lib/models/Setting';
import time from '@joplin/lib/time';
interface Props {
@@ -21,26 +23,18 @@ interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
syncReport: any;
syncStarted: boolean;
syncPending: boolean;
syncReportIsVisible: boolean;
syncReportLogExpanded: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- The generated report does not currently have a type
const syncCompletedWithoutError = (syncReport: any) => {
return syncReport.completedTime && (!syncReport.errors || !syncReport.errors.length);
};
const SidebarComponent = (props: Props) => {
const renderSynchronizeButton = (type: string) => {
const label = type === 'sync' ? _('Synchronise') : _('Cancel');
const nothingToSync = type === 'sync' && !props.syncPending && syncCompletedWithoutError(props.syncReport);
const iconName = nothingToSync ? 'fas fa-check' : 'icon-sync';
return (
<StyledSynchronizeButton
level={ButtonLevel.SidebarSecondary}
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'} ${nothingToSync ? '-synced' : ''}`}
iconName={iconName}
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'}`}
iconName="icon-sync"
key="sync_button"
title={label}
onClick={() => {
@@ -62,46 +56,56 @@ const SidebarComponent = (props: Props) => {
resourceFetcherText = _('Fetching resources: %d/%d', props.resourceFetcher.fetchingCount, props.resourceFetcher.toFetchCount);
}
const syncReportExpanded = props.syncReportLogExpanded;
const toggleSyncReport = useCallback(() => {
Setting.setValue('syncReportLogExpanded', !syncReportExpanded);
}, [syncReportExpanded]);
const lines = Synchronizer.reportToLines(props.syncReport);
if (resourceFetcherText) lines.push(resourceFetcherText);
if (decryptionReportText) lines.push(decryptionReportText);
const syncReportText = [];
for (let i = 0; i < lines.length; i++) {
syncReportText.push(
<StyledSyncReportText key={i}>
{lines[i]}
</StyledSyncReportText>,
);
}
const completedTime = props.syncReport && props.syncReport.completedTime
? time.formatMsToLocal(props.syncReport.completedTime)
: null;
const syncButton = renderSynchronizeButton(props.syncStarted ? 'cancel' : 'sync');
const hasSyncReport = syncReportText.length > 0;
const syncReportComp = !hasSyncReport || !props.syncReportIsVisible ? null : (
<StyledSyncReport key="sync_report" id="sync-report">
{syncReportText}
</StyledSyncReport>
);
const syncReportToggle = (
// Toggle to show/hide sync log output
const toggleButton = (
<button
className="sync-report-toggle"
style={{ color: theme.color2 }}
onClick={() => Setting.toggle('syncReportIsVisible')}
aria-label={_('Sync report')}
aria-expanded={props.syncReportIsVisible}
aria-controls="sync-report"
className="sidebar-sync-toggle"
onClick={toggleSyncReport}
aria-expanded={syncReportExpanded}
aria-label={syncReportExpanded ? _('Hide sync log') : _('Show sync log')}
title={syncReportExpanded ? _('Hide sync log') : _('Show sync log')}
>
<i className={`fas fa-chevron-${props.syncReportIsVisible ? 'down' : 'up'}`}/>
<i className={`fas fa-caret-${syncReportExpanded ? 'down' : 'right'}`} />
{(completedTime || props.syncStarted) ? (
<span className="timestamp">
{props.syncStarted ? _('Last sync: In progress...') : _('Last sync: %s', completedTime)}
</span>
) : ''}
</button>
);
// Sync log output, only visible when expanded
const syncReportComp = (syncReportExpanded && lines.length > 0) ? (
<StyledSyncReport key="sync_report">
{lines.map((line, i) => (
<StyledSyncReportText key={i}>
{line}
</StyledSyncReportText>
))}
</StyledSyncReport>
) : null;
return (
<StyledRoot className='sidebar _scrollbar2' role='navigation' aria-label={_('Sidebar')}>
<div style={{ flex: 1 }}><FolderAndTagList/></div>
<div style={{ flex: 1 }}><FolderAndTagList /></div>
<div style={{ flex: 0, padding: theme.mainPadding }}>
{syncReportToggle}
{(completedTime || props.syncStarted) ? toggleButton : null}
{syncReportComp}
{syncButton}
</div>
@@ -113,7 +117,6 @@ const mapStateToProps = (state: AppState) => {
return {
searches: state.searches,
syncStarted: state.syncStarted,
syncPending: state.syncPending,
syncReport: state.syncReport,
selectedSearchId: state.selectedSearchId,
selectedSmartFilterId: state.selectedSmartFilterId,
@@ -122,7 +125,7 @@ const mapStateToProps = (state: AppState) => {
collapsedFolderIds: state.collapsedFolderIds,
decryptionWorker: state.decryptionWorker,
resourceFetcher: state.resourceFetcher,
syncReportIsVisible: state.settings.syncReportIsVisible,
syncReportLogExpanded: state.settings.syncReportLogExpanded,
};
};
+2 -1
View File
@@ -6,4 +6,5 @@
@use 'styles/sidebar-header-container.scss';
@use 'styles/sidebar-spacer-item.scss';
@use 'styles/sidebar-header-button.scss';
@use 'styles/sidebar-sync-button.scss';
@use 'styles/sidebar-sync-button.scss';
@use 'styles/sidebar-sync-toggle.scss';
@@ -105,7 +105,7 @@ export const StyledSyncReport = styled.div`
opacity: 0.5;
display: flex;
flex-direction: column;
margin-left: 5px;
margin-left: 25px;
margin-right: 5px;
margin-bottom: 10px;
word-wrap: break-word;
@@ -5,25 +5,6 @@
}
}
@keyframes icon-fade-in-a {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes icon-fade-in-b {
from { opacity: 0; }
to { opacity: 1; }
}
.sidebar-sync-button > .icon {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 16px !important;
height: 16px;
margin-right: 8px !important;
}
.sidebar-sync-button {
&.-syncing > .icon {
animation: icon-infinite-rotation 1s linear infinite;
@@ -32,29 +13,4 @@
animation: none;
}
}
&:not(.-syncing).-synced > .icon {
animation: icon-fade-in-a 300ms ease-in-out;
font-size: 0.85em;
}
&:not(.-syncing):not(.-synced) > .icon {
animation: icon-fade-in-b 300ms ease-in-out;
}
}
.sync-report-toggle {
display: block;
width: 100%;
background: none;
border: none;
padding: 0;
text-align: center;
cursor: pointer;
opacity: 0.5;
margin-bottom: 4px;
&:hover {
opacity: 1;
}
}
@@ -0,0 +1,31 @@
.sidebar-sync-toggle {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 4px 5px 4px 5px;
background: none;
border: none;
color: var(--joplin-color2);
opacity: 0.5;
cursor: pointer;
width: 100%;
font-size: calc(var(--joplin-font-size) * 1.6);
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
i {
width: 16px;
display: inline-flex;
justify-content: center;
font-size: calc(var(--joplin-toolbar-icon-size) * 0.8);
}
>.timestamp {
font-size: 0.6em;
margin-left: 4px;
}
}
@@ -5,7 +5,7 @@ const { connect } = require('react-redux');
const { _ } = require('@joplin/lib/locale');
const { themeStyle } = require('../global-style.js');
import { AppState } from '../../utils/types';
import { generateApplicationConfirmUrl, reducer, checkIfLoginWasSuccessful, defaultState } from '@joplin/lib/services/joplinCloudUtils';
import { generateApplicationConfirmUrl, reducer, checkIfLoginWasSuccessful, saveApplicationAuthId, defaultState } from '@joplin/lib/services/joplinCloudUtils';
import { uuidgen } from '@joplin/lib/uuid';
import { Button } from 'react-native-paper';
import createRootStyle from '../../utils/createRootStyle';
@@ -107,6 +107,7 @@ const JoplinCloudScreenComponent = (props: Props) => {
if (state.next === 'LINK_USED') {
dispatch({ type: 'LINK_USED' });
}
saveApplicationAuthId(applicationAuthId);
periodicallyCheckForCredentials();
};
@@ -1678,7 +1678,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
!note || !note.body.trim() ? null : (
<NoteBodyViewer
style={this.styles().noteBodyViewer}
paddingBottom={0}
// Extra bottom padding to make it possible to scroll past the
// action button (so that it doesn't overlap the text)
paddingBottom={150}
noteBody={note.body}
noteMarkupLanguage={note.markup_language}
noteResources={this.state.noteResources}
@@ -408,7 +408,7 @@
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Joplin/Pods-Joplin-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/QuickCrypto/OpenSSL.framework/OpenSSL",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
+31 -28
View File
@@ -113,30 +113,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- QuickCrypto (1.0.19):
- hermes-engine
- NitroModules
- RCTRequired
- RCTTypeSafety
- React-callinvoker
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- OpenSSL-Universal (3.3.3001)
- RCTDeprecation (0.81.6)
- RCTRequired (0.81.6)
- RCTTypeSafety (0.81.6):
@@ -1490,6 +1467,30 @@ PODS:
- React-Core
- react-native-quick-base64 (2.2.2):
- React-Core
- react-native-quick-crypto (0.7.17):
- hermes-engine
- OpenSSL-Universal
- RCTRequired
- RCTTypeSafety
- React
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-rsa-native (2.0.5):
- React
- react-native-saf-x (3.6.0):
@@ -2183,7 +2184,6 @@ DEPENDENCIES:
- JoplinCommonShareExtension (from `ShareExtension`)
- JoplinRNShareExtension (from `ShareExtension`)
- NitroModules (from `../node_modules/react-native-nitro-modules`)
- QuickCrypto (from `../node_modules/react-native-quick-crypto`)
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
- RCTRequired (from `../node_modules/react-native/Libraries/Required`)
- RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
@@ -2225,6 +2225,7 @@ DEPENDENCIES:
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-quick-base64 (from `../node_modules/react-native-quick-base64`)
- react-native-quick-crypto (from `../node_modules/react-native-quick-crypto`)
- react-native-rsa-native (from `../node_modules/react-native-rsa-native`)
- "react-native-saf-x (from `../node_modules/@joplin/react-native-saf-x`)"
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
@@ -2285,6 +2286,7 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- libwebp
- OpenSSL-Universal
- SDWebImage
- SDWebImageWebPCoder
- ZXingObjC
@@ -2323,8 +2325,6 @@ EXTERNAL SOURCES:
:path: ShareExtension
NitroModules:
:path: "../node_modules/react-native-nitro-modules"
QuickCrypto:
:path: "../node_modules/react-native-quick-crypto"
RCTDeprecation:
:path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
RCTRequired:
@@ -2405,6 +2405,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-community/netinfo"
react-native-quick-base64:
:path: "../node_modules/react-native-quick-base64"
react-native-quick-crypto:
:path: "../node_modules/react-native-quick-crypto"
react-native-rsa-native:
:path: "../node_modules/react-native-rsa-native"
react-native-saf-x:
@@ -2536,7 +2538,7 @@ SPEC CHECKSUMS:
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
NitroModules: 114b4f79e10be9b202723e721d6a54382fa4b599
QuickCrypto: 0708a392535332365b7f4a16a0b229482be9a1e0
OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2
RCTDeprecation: ff38238d8b6ddfe1fcfeb2718d1c14da9564c1c3
RCTRequired: 5916f53ff05efcc2bf095b0de01a0e5b00112a55
RCTTypeSafety: bfbd8d69504a7459a65040705fc6ce10806c383b
@@ -2577,6 +2579,7 @@ SPEC CHECKSUMS:
react-native-image-picker: 48d850454b4a389753053e1d7378b624d3b47d77
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-quick-base64: 6568199bb2ac8e72ecdfdc73a230fbc5c1d3aac4
react-native-quick-crypto: 1a2467cf17acc57dce5bb076a953978b79e3fd11
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
react-native-saf-x: 50d176763ed692b379c190bf55ae7293a3ee09bb
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
+2 -2
View File
@@ -35,7 +35,7 @@
"@react-native-community/datetimepicker": "8.5.1",
"@react-native-community/geolocation": "3.4.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-community/push-notification-ios": "1.12.0",
"@react-native-community/push-notification-ios": "1.11.0",
"@react-native-documents/picker": "10.1.7",
"@react-native-vector-icons/fontawesome5": "patch:@react-native-vector-icons/fontawesome5@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-fontawesome5-npm-12.3.0-a1ca46610f.patch",
"@react-native-vector-icons/get-image": "12.3.0",
@@ -74,7 +74,7 @@
"react-native-popup-menu": "0.17.0",
"react-native-quick-actions": "0.3.13",
"react-native-quick-base64": "2.2.2",
"react-native-quick-crypto": "1.0.19",
"react-native-quick-crypto": "0.7.17",
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.6.2",
"react-native-securerandom": "1.0.1",
+5 -21
View File
@@ -23,15 +23,6 @@ import { AppState as RNAppState, EmitterSubscription, View, Text, Linking, Nativ
import getResponsiveValue from './components/getResponsiveValue';
import NetInfo, { NetInfoSubscription } from '@react-native-community/netinfo';
const DropdownAlert = require('react-native-dropdownalert').default;
// Mirrors the DropdownAlertData type from react-native-dropdownalert
interface DropdownAlertData {
type?: string;
title?: string;
message?: string;
interval?: number;
resolve?: (_value: DropdownAlertData)=> void;
}
import SafeAreaView from './components/SafeAreaView';
const { connect, Provider } = require('react-redux');
import { Provider as PaperProvider, MD3DarkTheme, MD3LightTheme } from 'react-native-paper';
@@ -112,7 +103,7 @@ import SamlShared from '@joplin/lib/components/shared/SamlShared';
import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
import DocumentScanner from './components/screens/DocumentScanner/DocumentScanner';
import buildStartupTasks from './utils/buildStartupTasks';
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import appReducer from './utils/appReducer';
import SyncWizard from './components/SyncWizard/SyncWizard';
import Synchronizer from '@joplin/lib/Synchronizer';
@@ -120,15 +111,6 @@ import Synchronizer from '@joplin/lib/Synchronizer';
const logger = Logger.create('root');
const perfLogger = PerformanceLogger.create();
interface DropdownAlertWrapperProps {
alert: (func: (data?: DropdownAlertData)=> Promise<DropdownAlertData>)=> void;
}
const DropdownAlertWrapper = ({ alert }: DropdownAlertWrapperProps) => {
const insets = useSafeAreaInsets();
return <DropdownAlert alert={alert} translucent alertViewStyle={{ padding: 8, marginTop: insets.top }} />;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let storeDispatch: any = function(_action: any) {};
@@ -305,7 +287,8 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
private themeChangeListener_: NativeEventSubscription|null = null;
private keyboardShowListener_: EmitterSubscription|null = null;
private keyboardHideListener_: EmitterSubscription|null = null;
private dropdownAlert_: (data?: DropdownAlertData)=> Promise<DropdownAlertData> = (_data?: DropdownAlertData) => new Promise<DropdownAlertData>(res => res);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private dropdownAlert_ = (_data: any) => new Promise<any>(res => res);
private callbackUrl: string|null = null;
private lastSyncStarted_ = false;
@@ -792,9 +775,10 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
<View style={{ flex: 1, backgroundColor: theme.backgroundColor }}>
{ shouldShowMainContent && <AppNav screens={appNavInit} dispatch={this.props.dispatch} /> }
</View>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied */}
<DropdownAlert alert={(func: any) => (this.dropdownAlert_ = func)} />
<SyncWizard/>
</SafeAreaView>
<DropdownAlertWrapper alert={(func) => { this.dropdownAlert_ = func; }} />
</View>
</SideMenu>
<PluginRunnerWebView />
+2 -2
View File
@@ -29,7 +29,7 @@ const pbkdf2Raw = (password: string, salt: CryptoBuffer, iterations: number, key
const encryptRaw = (data: CryptoBuffer, algorithm: CipherAlgorithm, key: CryptoBuffer, iv: CryptoBuffer, authTagLength: number, associatedData: CryptoBuffer) => {
const cipher = QuickCrypto.createCipheriv(algorithm, key, iv, { authTagLength: authTagLength } as CipherGCMOptions) as unknown as CipherGCM;
const cipher = QuickCrypto.createCipheriv(algorithm, key, iv, { authTagLength: authTagLength } as CipherGCMOptions) as CipherGCM;
cipher.setAAD(associatedData, { plaintextLength: Buffer.byteLength(data) });
@@ -41,7 +41,7 @@ const encryptRaw = (data: CryptoBuffer, algorithm: CipherAlgorithm, key: CryptoB
const decryptRaw = (data: CryptoBuffer, algorithm: CipherAlgorithm, key: CryptoBuffer, iv: CryptoBuffer, authTagLength: number, associatedData: CryptoBuffer) => {
const decipher = QuickCrypto.createDecipheriv(algorithm, key, iv, { authTagLength: authTagLength } as CipherGCMOptions) as unknown as DecipherGCM;
const decipher = QuickCrypto.createDecipheriv(algorithm, key, iv, { authTagLength: authTagLength } as CipherGCMOptions) as DecipherGCM;
const authTag = data.subarray(-authTagLength);
const encryptedData = data.subarray(0, data.byteLength - authTag.byteLength);
@@ -13,6 +13,7 @@ import { loadKeychainServiceAndSettings } from '@joplin/lib/services/SettingUtil
import { setLocale } from '@joplin/lib/locale';
import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer';
import SyncTargetJoplinCloud from '@joplin/lib/SyncTargetJoplinCloud';
import { completePendingAuthentication } from '@joplin/lib/services/joplinCloudUtils';
import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive';
import initProfile from '@joplin/lib/services/profileConfig/initProfile';
const VersionInfo = require('react-native-version-info').default;
@@ -418,6 +419,9 @@ const buildStartupTasks = (
addTask('buildStartupTasks/run migrations', async () => {
await MigrationService.instance().run();
});
addTask('buildStartupTasks/complete pending Joplin Cloud auth', async () => {
await completePendingAuthentication();
});
addTask('buildStartupTasks/set up background tasks', async () => {
initializeUserFetcher();
PoorManIntervals.setInterval(() => { void userFetcher(); }, 1000 * 60 * 60);
@@ -174,19 +174,6 @@ const searchExtension = (onEvent: OnEventCallback, settings: EditorSettings): Ex
},
} : undefined),
// On mobile, scrolling is handled externally (page-level native scroll), so
// .cm-scroller has no internal scroll. A @codemirror/view upgrade (6.35→6.41)
// added rect-clipping after each failed scroll attempt, which prevents the
// fallback window.scrollBy from firing. Use native element.scrollIntoView to
// fix findNext/findPrevious and GoDocEnd.
settings.useExternalSearch ? EditorView.scrollHandler.of((view, range) => {
const { node } = view.domAtPos(range.head);
const el = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
if (!el) return false;
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
return true;
}) : [],
autoScrollToMatchPlugin,
EditorState.transactionExtender.of((tr) => {
@@ -445,6 +445,8 @@ const builtInMetadata = (Setting: typeof SettingType) => {
secure: true,
},
'sync.10.pendingAuthId': { value: '', type: SettingItemType.String, public: false },
'sync.10.inboxEmail': { value: '', type: SettingItemType.String, public: false },
'sync.10.inboxId': { value: '', type: SettingItemType.String, public: false },
@@ -1479,7 +1481,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, storage: SettingStorage.File, isGlobal: true, public: false, appTypes: [AppType.Desktop] },
tagHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
folderHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
syncReportIsVisible: { value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
syncReportLogExpanded: { value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
editor: { value: '', type: SettingItemType.String, subType: 'file_path_and_args', storage: SettingStorage.File, isGlobal: true, public: true, appTypes: [AppType.Cli, AppType.Desktop], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') },
'export.pdfPageSize': { value: 'A4', type: SettingItemType.String, advanced: true, storage: SettingStorage.File, isGlobal: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page size for PDF export'), options: () => {
return {
@@ -67,16 +67,6 @@ export async function checkDecryptTestData(data: DecryptTestData, options: Check
messages.push(`Failed to decrypt data: Error: ${error}`);
}
try {
const decrypted = await EncryptionService.instance().decrypt(data.method, `${data.password}-bad`, data.ciphertext);
messages.push('Data could be decrypted with incorrect password');
messages.push('Expected:', data.plaintext);
messages.push('Got:', decrypted);
hasError = true;
} catch (error) {
messages.push(`Could not decrypt data with an invalid password (${error})`);
}
if (hasError && options.throwOnError) {
const label = options.testLabel ? ` (test ${options.testLabel})` : '';
throw new Error(`Testing Crypto failed${label}: \n${messages.join('\n')}`);
@@ -299,14 +299,6 @@ describe('InteropService_Importer_OneNote', () => {
expect(normalizeNoteForSnapshot(note2Content)).toMatchSnapshot();
});
it('should import vertically-scaled ink', async () => {
const notes = await importNote(`${supportDir}/onenote/scaled_ink.one`);
const note = notes.find(n => n.title === 'Scaled');
expectWithInstructions(note).toBeTruthy();
expectWithInstructions(normalizeNoteForSnapshot(note.body)).toMatchSnapshot();
});
it('should support directly importing .one files', async () => {
const notes = await importNote(`${supportDir}/onenote/onenote_desktop.one`);
@@ -376,14 +368,4 @@ describe('InteropService_Importer_OneNote', () => {
// The other section should import successfully
expect(notes.map(note => note.title).sort()).toEqual(['Test note', 'Test section']);
});
it('should import nested ink', async () => {
const notes = await importNote(`${supportDir}/onenote/desktop_missing_ink.one`);
expect(
notes
.filter(note => note.title === 'Ink Missing - only one example missing part')
.map(note => normalizeNoteForSnapshot(note.body))
.sort(),
).toMatchSnapshot();
});
});
@@ -35,12 +35,6 @@ const getOneNoteConverter = (): NativeOneNoteConverter => {
}
};
const setEnableUnresponsiveCheck = (enabled: boolean) => {
if (shim.isElectron()) {
shim.electronBridge().setEnableUnresponsiveCheck(enabled);
}
};
// See onenote-converter README.md for more information
export default class InteropService_Importer_OneNote extends InteropService_Importer_Base {
protected importedNotes: Record<string, NoteEntity> = {};
@@ -125,11 +119,6 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
}
try {
// HACK: The OneNote importer currently runs in the renderer process on desktop.
// If importing a large file takes a long time, the "unresponsive" dialog can be
// shown. Work around this by temporarily disabling the dialog:
setEnableUnresponsiveCheck(false);
await oneNoteConverter(notebookFilePath, resolve(outputDirectory2), notebookBaseDir);
} catch (error) {
// Forward only the error message. Usually the stack trace points to bytes in the WASM file.
@@ -137,8 +126,6 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
// length for auto-creating a forum post:
this.options_.onError?.(error.message ?? error);
console.error(error);
} finally {
setEnableUnresponsiveCheck(true);
}
}
@@ -942,66 +942,6 @@ exports[`InteropService_Importer_OneNote should import a simple OneNote notebook
</body></html>"
`;
exports[`InteropService_Importer_OneNote should import nested ink 1`] = `
[
"<!DOCTYPE HTML>
<html><head>
<meta charset="UTF-8">
<title>Ink Missing - only one example missing part</title>
<style>
/* (For testing: Removed default CSS) */
</style>
</head>
<body>
<div class="title" style="left: 48px; position: absolute; top: 24px;"><div class="container-outline" style="width: 753px;"><div class="outline-element" style="margin-left: 0px;"><span style="font-family: Calibri Light; font-size: 20pt; line-height: 32px;">Ink Missing - only one example missing part</span></div>
</div><div class="container-outline" style="width: 753px;"><div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(118,118,118); font-family: Calibri; font-size: 10pt; line-height: 16px;">Mittwoch, 1. April 2026</span></div>
<div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(118,118,118); font-family: Calibri; font-size: 10pt; line-height: 16px;">04:25</span></div>
</div></div><div class="container-outline" style="left: 277px; position: absolute; top: 485px; width: 474px;"><div class="outline-element" style="margin-left: 0px;"><p><span style="height: 54px; width: 90px;" class="ink-text"><img style="height: 54px; left: 22.9px; pointer-events: none; position: absolute; top: -2.61px; width: 90px;" src=":/id-here"></span><span class="ink-space" style="padding-left: 25px; padding-top: 77px;"></span><span style="height: 86px; width: 283px;" class="ink-text"><img style="height: 86px; left: -2.61px; pointer-events: none; position: absolute; top: 5.78px; width: 283px;" src=":/id-here"></span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p><span style="height: 70px; width: 190px;" class="ink-text"><img style="height: 70px; left: -2.61px; pointer-events: none; position: absolute; top: -10.05px; width: 190px;" src=":/id-here"></span></p></div>
<div class="outline-element" style="margin-left: 0px;"><p><span style="height: 68px; width: 344px;" class="ink-text"><img style="height: 68px; left: 34.28px; pointer-events: none; position: absolute; top: 7.56px; width: 344px;" src=":/id-here"></span></p></div>
</div><div class="container-outline" style="left: 262px; position: absolute; top: 130px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; line-height: 17px;">&nbsp;</p></div>
</div><div class="container-outline" style="left: 285px; position: absolute; top: 243px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt;">&nbsp;</p></div>
</div><div class="container-outline" style="left: 560px; position: absolute; top: 195px; width: 624px;"><div class="outline-element" style="margin-left: 0px;"><p style="font-family: Calibri; font-size: 11pt; line-height: 17px;">&nbsp;</p></div>
</div>
</body></html>",
]
`;
exports[`InteropService_Importer_OneNote should import vertically-scaled ink 1`] = `
"<!DOCTYPE HTML>
<html><head>
<meta charset="UTF-8">
<title>Scaled</title>
<style>
/* (For testing: Removed default CSS) */
</style>
</head>
<body>
<div class="title" style="left: 48px; position: absolute; top: 24px;"><div class="container-outline" style="width: 624px;"><div class="outline-element" style="margin-left: 0px;"><span style="font-family: Calibri Light; font-size: 20pt; line-height: 32px;">Scaled</span></div>
</div><div class="container-outline" style="width: 624px;"><div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(118,118,118); font-family: Calibri; font-size: 10pt; line-height: 16px;">Wednesday, April 15, 2026</span></div>
<div class="outline-element" style="margin-left: 0px;"><span style="color: rgb(118,118,118); font-family: Calibri; font-size: 10pt; line-height: 16px;">12:36 AM</span></div>
</div></div><img style="height: 492.21px; left: 128.13px; pointer-events: none; position: absolute; top: 144.3px; width: 187.42px;" src=":/id-here">
</body></html>"
`;
@@ -1474,7 +1414,7 @@ exports[`InteropService_Importer_OneNote should use default value for EntityGuid
</div><div class="container-outline" style="left: 72px; position: absolute; top: 475px; width: 146px;"><div class="outline-element" style="margin-left: 0px;"><p style="color: rgb(91,155,213); font-family: Trebuchet MS; font-size: 12pt; line-height: 19px;">Customer Success Management</p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="color: rgb(91,155,213); font-family: Trebuchet MS; font-size: 12pt; line-height: 19px;">and</p></div>
<div class="outline-element" style="margin-left: 0px;"><p style="color: rgb(91,155,213); font-family: Trebuchet MS; font-size: 12pt; line-height: 19px;">Training</p></div>
</div><img style="height: 173px; left: 57.15px; pointer-events: none; position: absolute; top: 443.15px; width: 310px;" src=":/id-here">
</div><img style="height: 170px; left: 57.15px; pointer-events: none; position: absolute; top: 435.1px; width: 310px;" src=":/id-here">
+29
View File
@@ -6,6 +6,9 @@ import { _ } from '../locale';
import eventManager, { EventName } from '../eventManager';
import { reg } from '../registry';
import SyncTargetRegistry from '../SyncTargetRegistry';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('joplinCloudUtils');
type ActionType = 'LINK_USED' | 'COMPLETED' | 'ERROR';
type Action = {
@@ -90,6 +93,9 @@ export const generateApplicationConfirmUrl = async (confirmUrl: string) => {
return `${confirmUrl}?${searchParams.toString()}`;
};
export const saveApplicationAuthId = (applicationAuthId: string) => {
Setting.setValue('sync.10.pendingAuthId', applicationAuthId);
};
// We have isWaitingResponse inside the function to avoid any state from lingering
// after an error occurs. E.g.: if the function would throw an error while isWaitingResponse
@@ -116,6 +122,7 @@ export const checkIfLoginWasSuccessful = async (applicationsUrl: string) => {
Setting.setValue('sync.10.username', jsonBody.id);
Setting.setValue('sync.10.password', jsonBody.password);
Setting.setValue('sync.target', SyncTargetRegistry.nameToId('joplinCloud'));
Setting.setValue('sync.10.pendingAuthId', '');
const fileApi = await reg.syncTarget().fileApi();
await fileApi.driver().api().loadSession();
@@ -126,3 +133,25 @@ export const checkIfLoginWasSuccessful = async (applicationsUrl: string) => {
return performLoginRequest();
};
// If the app was killed during the OAuth flow (common on Android), the
// pending auth ID is still saved. On startup we check whether the server
// has already confirmed the authorisation and, if so, save the credentials.
export const completePendingAuthentication = async () => {
const pendingAuthId = Setting.value('sync.10.pendingAuthId');
if (!pendingAuthId) return;
const apiBaseUrl = Setting.value('sync.10.path');
const applicationsUrl = `${apiBaseUrl}/api/application_auth/${pendingAuthId}`;
try {
const result = await checkIfLoginWasSuccessful(applicationsUrl);
if (result && result.success) {
logger.info('Completed pending Joplin Cloud authentication');
}
} catch (error) {
logger.error('Could not complete pending authentication:', error);
} finally {
Setting.setValue('sync.10.pendingAuthId', '');
}
};
+1 -1
View File
@@ -320,7 +320,7 @@ export async function createResourcesFromPaths(mediaFiles: DownloadedMediaFile[]
const resource = await shim.createResourceFromPath(mediaFile.path);
return { ...mediaFile, resource };
} catch (error) {
logger.info(`Cannot create resource for ${mediaFile.originalUrl}`, error);
logger.warn(`Cannot create resource for ${mediaFile.originalUrl}`, error);
return { ...mediaFile, resource: null };
}
};
+1 -1
View File
@@ -491,7 +491,7 @@ const shim = {
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
writeImageToFile: (_image: any, _format: any, _filePath: string): Promise<void> => {
writeImageToFile: (_image: any, _format: any, _filePath: string): void => {
throw new Error('Not implemented');
},
@@ -30,17 +30,9 @@ function normalizeAndWriteFile(filePath, data) {
function fileReader(path) {
const fd = fs.openSync(path);
// TODO: When Node v20 is EOL, replace this with the { bigint: true }
// parameter variant.
const size = BigInt(fs.fstatSync(fd).size);
const size = fs.fstatSync(fd).size;
return {
read: (bigPosition, bigLength) => {
// Rust uses u64 for position/length which is transferred to JS as a BigInt.
// Convert:
const length = Number(bigLength);
const position = Number(bigPosition);
read: (position, length) => {
const data = Buffer.alloc(length);
const sizeRead = fs.readSync(fd, data, { length, position });
@@ -1,7 +1,6 @@
//! OneNote parsing error handling.
use std::borrow::Cow;
use std::num::TryFromIntError;
use std::{io, string};
use thiserror::Error;
@@ -28,11 +27,7 @@ impl From<ErrorKind> for Error {
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
if err.kind() == std::io::ErrorKind::UnexpectedEof {
ErrorKind::UnexpectedEof(err.to_string().into()).into()
} else {
ErrorKind::from(err).into()
}
ErrorKind::from(err).into()
}
}
@@ -54,12 +49,6 @@ impl From<widestring::error::Utf16Error> for Error {
}
}
impl From<TryFromIntError> for Error {
fn from(err: TryFromIntError) -> Self {
ErrorKind::from(err).into()
}
}
impl From<uuid::Error> for Error {
fn from(err: uuid::Error) -> Self {
ErrorKind::from(err).into()
@@ -124,12 +113,6 @@ pub enum ErrorKind {
#[error("Failed to resolve: {0}")]
ResolutionFailed(Cow<'static, str>),
#[error("Type conversion failed: {err}")]
TypeConversionFailed {
#[from]
err: TryFromIntError,
},
/// A malformed UUID was encountered
#[error("Invalid UUID: {err}")]
InvalidUuid {
@@ -2,9 +2,7 @@ use sanitize_filename::{Options as SanitizeOptions, sanitize_with_options};
use std::io::{Read, Seek};
pub type ApiResult<T> = std::result::Result<T, std::io::Error>;
pub trait FileHandle: Read + Seek {
fn byte_length(&self) -> u64;
}
pub trait FileHandle: Read + Seek {}
pub trait FileApiDriver: Send + Sync {
fn is_windows(&self) -> bool;
@@ -2,7 +2,6 @@ use super::ApiResult;
use super::FileApiDriver;
use super::FileHandle;
use std::fs;
use std::io::BufReader;
use std::path;
use std::path::Path;
@@ -39,7 +38,7 @@ impl FileApiDriver for FileApiDriverImpl {
}
fn open_file(&self, path: &str) -> ApiResult<Box<dyn FileHandle>> {
Ok(Box::new(BufReader::new(fs::File::open(path)?)))
Ok(Box::new(fs::File::open(path)?))
}
fn write_file(&self, path: &str, data: &[u8]) -> ApiResult<()> {
@@ -88,11 +87,7 @@ impl FileApiDriver for FileApiDriverImpl {
}
}
impl FileHandle for BufReader<fs::File> {
fn byte_length(&self) -> u64 {
self.get_ref().metadata().map(|m| m.len()).unwrap_or(0)
}
}
impl FileHandle for fs::File {}
#[cfg(test)]
mod test {
@@ -48,12 +48,12 @@ extern "C" {
#[wasm_bindgen(structural, method, catch)]
fn read(
this: &JsFileHandle,
offset: u64,
size: u64,
offset: usize,
size: usize,
) -> std::result::Result<Uint8Array, JsValue>;
#[wasm_bindgen(structural, method)]
fn size(this: &JsFileHandle) -> u64;
fn size(this: &JsFileHandle) -> usize;
#[wasm_bindgen(structural, method, catch)]
fn close(this: &JsFileHandle) -> std::result::Result<(), JsValue>;
@@ -181,7 +181,7 @@ impl FileApiDriver for FileApiDriverImpl {
struct SeekableFileHandle {
handle: JsFileHandle,
offset: u64,
offset: usize,
}
impl Read for SeekableFileHandle {
@@ -193,12 +193,12 @@ impl Read for SeekableFileHandle {
0
};
let maximum_read_size = bytes_remaining.min(out.len() as u64);
let maximum_read_size = bytes_remaining.min(out.len());
match self.handle.read(self.offset, maximum_read_size) {
Ok(data) => {
let data = data.to_vec();
let size = data.len();
self.offset += size as u64;
self.offset += size;
// Verify that handle.read respected the maximum length:
if size > out.len() {
@@ -228,25 +228,25 @@ impl Seek for SeekableFileHandle {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
match pos {
SeekFrom::Start(pos) => {
self.offset = pos;
self.offset = pos as usize;
}
SeekFrom::Current(offset) => {
// Disallow seeking to a negative position
if offset < 0 && offset.unsigned_abs() > self.offset {
if offset < 0 && (-offset) as usize > self.offset {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Attempted to seek before the beginning of the file.",
));
}
self.offset = (self.offset as i64 + offset) as u64;
self.offset = (self.offset as i64 + offset) as usize;
}
SeekFrom::End(offset) => {
self.offset = self.handle.size();
self.seek(SeekFrom::Current(offset))?;
}
}
Ok(self.offset)
Ok(self.offset as u64)
}
}
@@ -261,8 +261,4 @@ impl Drop for SeekableFileHandle {
}
}
impl FileHandle for BufReader<SeekableFileHandle> {
fn byte_length(&self) -> u64 {
self.get_ref().handle.size()
}
}
impl FileHandle for BufReader<SeekableFileHandle> {}
@@ -1,182 +1,72 @@
use crate::{
FileHandle,
errors::{ErrorKind, Result},
};
use crate::errors::{ErrorKind, Result};
use bytes::Buf;
use paste::paste;
use std::{
cell::RefCell,
io::{Read, Seek, SeekFrom},
mem,
rc::Rc,
};
use std::mem;
macro_rules! try_get {
($this:ident, $typ:tt) => {{
if $this.remaining() < mem::size_of::<$typ>() as u64 {
if $this.buff.remaining() < mem::size_of::<$typ>() {
Err(ErrorKind::UnexpectedEof(format!("Getting {:}", stringify!($typ)).into()).into())
} else {
let mut buff = [0; mem::size_of::<$typ>()];
$this.read_exact(&mut buff)?;
let mut buff_ref: &[u8] = &mut buff;
Ok(paste! {buff_ref. [< get_ $typ >]()})
Ok(paste! {$this.buff. [< get_ $typ >]()})
}
}};
($this:ident, $typ:tt::$endian:tt) => {{
if $this.remaining() < mem::size_of::<$typ>() as u64 {
if $this.buff.remaining() < mem::size_of::<$typ>() {
Err(ErrorKind::UnexpectedEof(
format!("Getting {:} ({:})", stringify!($typ), stringify!($endian)).into(),
)
.into())
} else {
let mut buff = [0; mem::size_of::<$typ>()];
$this.read_exact(&mut buff)?;
let mut buff_ref: &[u8] = &mut buff;
Ok(paste! {buff_ref. [< get_ $typ _ $endian >]()})
Ok(paste! {$this.buff. [< get_ $typ _ $endian >]()})
}
}};
}
type ReaderFileHandle = Rc<RefCell<Box<dyn FileHandle>>>;
enum ReaderData<'a> {
/// Wraps a buffer owned by calling logic. This is more efficient for
/// small amounts of data.
BufferRef { buffer: &'a [u8] },
/// Wraps a handle to a file. This handles large amounts of data
/// that won't necessarily fit into memory.
/// Invariant: The internal file handle's offset should match the
/// `data_offset` of the main reader.
File(ReaderFileHandle),
}
pub struct Reader<'a> {
data: ReaderData<'a>,
data_len: u64,
data_offset: u64,
buff: &'a [u8],
original: &'a [u8],
}
pub struct ReaderOffset(u64);
impl<'a> Seek for Reader<'a> {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
let new_offset = match pos {
SeekFrom::Start(n) => n as i64,
SeekFrom::Current(n) => self.data_offset as i64 + n,
SeekFrom::End(n) => (self.data_len as i64) + n,
};
if new_offset < 0 || new_offset as u64 > self.data_len {
Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
format!(
"New offset {} is out-of-bounds (data length: {}).",
new_offset, self.data_len
),
))
} else {
self.data_offset = new_offset as u64;
// Sync the internal file with the new offset. This is done rather than seek the file
// directly to avoid inconsistency if e.g. the file resizes and we're seeking from the end.
if let ReaderData::File(f) = &mut self.data {
f.borrow_mut().seek(SeekFrom::Start(self.data_offset))?;
}
Ok(self.data_offset)
}
impl<'a> Clone for Reader<'a> {
fn clone(&self) -> Self {
let mut result = Self::new(self.original);
result
.advance(self.absolute_offset())
.expect("should re-advance to the original's position");
result
}
}
impl<'a> Reader<'a> {
pub fn new(buffer: &'a [u8]) -> Self {
pub fn new(data: &'a [u8]) -> Reader<'a> {
Reader {
data_len: buffer.len() as u64,
data_offset: 0,
data: ReaderData::BufferRef { buffer },
buff: data,
original: data,
}
}
pub fn read(&mut self, count: usize) -> Result<Vec<u8>> {
let mut buff = vec![0; count];
self.read_exact(&mut buff)?;
Ok(buff)
}
fn read_exact(&mut self, output: &mut [u8]) -> Result<()> {
let count = output.len();
if self.remaining() < count as u64 {
return Err(
ErrorKind::UnexpectedEof("Unexpected EOF (Reader.read_exact)".into()).into(),
);
pub fn read(&mut self, cnt: usize) -> Result<&'a [u8]> {
if self.remaining() < cnt {
return Err(ErrorKind::UnexpectedEof("Unexpected EOF (Reader.read)".into()).into());
}
match &mut self.data {
ReaderData::BufferRef { buffer } => {
let start = self.data_offset as usize;
(&buffer[start..start + count]).copy_to_slice(output);
}
ReaderData::File(file) => {
file.borrow_mut().read_exact(output)?;
}
};
self.data_offset += count as u64;
let data = &self.buff[0..cnt];
self.buff.advance(cnt);
Ok(())
Ok(data)
}
pub fn peek_u8(&mut self) -> Result<Option<u8>> {
match &mut self.data {
ReaderData::BufferRef { buffer, .. } => {
Ok(buffer.get(self.data_offset as usize).copied())
}
ReaderData::File(file) => {
let mut file = file.borrow_mut();
let mut buf = [0u8];
let read_result = file.read(&mut buf);
// Reset the original position
file.seek(SeekFrom::Start(self.data_offset))?;
match read_result {
Ok(size) => Ok(if size < 1 { None } else { Some(buf[0]) }),
Err(error) => Err(error)?,
}
}
}
pub fn bytes(&self) -> &[u8] {
self.buff.chunk()
}
pub fn as_data_ref(&mut self, size: usize) -> Result<ReaderDataRef> {
if self.remaining() < size as u64 {
return Err(
ErrorKind::UnexpectedEof("Unexpected EOF (Reader.as_data_ref)".into()).into(),
);
}
match &mut self.data {
ReaderData::BufferRef { buffer } => {
let start = self.data_offset as usize;
// Cloning needs to be done early with BufferRef, since we don't own the original
// data. Large data should generally use `ReaderData::File`.
Ok(ReaderDataRef::Vec(buffer[start..start + size].to_vec()))
}
ReaderData::File(file) => Ok(ReaderDataRef::FilePointer {
file: file.clone(),
offset: self.data_offset,
size,
}),
}
pub fn remaining(&self) -> usize {
self.buff.remaining()
}
pub fn remaining(&self) -> u64 {
assert!(self.data_len >= self.data_offset);
self.data_len - self.data_offset
}
pub fn advance(&mut self, count: u64) -> Result<()> {
pub fn advance(&mut self, count: usize) -> Result<()> {
if self.remaining() < count {
return Err(ErrorKind::UnexpectedEof(
format!(
@@ -189,23 +79,41 @@ impl<'a> Reader<'a> {
.into());
}
assert!(count < i64::MAX as u64);
self.seek(SeekFrom::Current(count as i64))?;
self.buff.advance(count);
Ok(())
}
pub fn save_position(&self) -> ReaderOffset {
ReaderOffset(self.data_offset)
pub fn absolute_offset(&self) -> usize {
// Use pointer arithmetic (in a way similar to the [subslice offset](https://docs.rs/crate/subslice-offset/latest/source/src/lib.rs)
// crate and [this StackOverflow post](https://stackoverflow.com/questions/50781561/how-to-find-the-starting-offset-of-a-string-slice-of-another-string/50781657))
// to calculate the offset.
let offset = (self.buff.as_ptr() as usize) - (self.original.as_ptr() as usize);
if offset > self.original.len() {
panic!("self.buff must be a subslice of self.original!");
}
offset
}
pub fn restore_position(&mut self, offset: ReaderOffset) -> Result<()> {
self.seek(SeekFrom::Start(offset.0))?;
Ok(())
}
pub fn with_updated_bounds(&self, start: usize, end: usize) -> Result<Reader<'a>> {
if start > self.original.len() {
return Err(ErrorKind::UnexpectedEof(
"Reader.with_updated_bounds: start is out of bounds".into(),
)
.into());
}
if end > self.original.len() {
return Err(ErrorKind::UnexpectedEof(
"Reader.with_updated_bounds: end is out of bounds".into(),
)
.into());
}
pub fn offset(&self) -> u64 {
self.data_offset
Ok(Reader {
buff: &self.original[start..end],
original: self.original,
})
}
pub fn get_u8(&mut self) -> Result<u8> {
@@ -233,61 +141,6 @@ impl<'a> Reader<'a> {
}
}
impl<'a> TryFrom<Box<dyn FileHandle>> for Reader<'a> {
type Error = crate::errors::Error;
fn try_from(mut handle: Box<dyn FileHandle>) -> Result<Self> {
let initial_offset = handle.seek(SeekFrom::Current(0))?;
Ok(Self {
data_len: handle.byte_length(),
data_offset: initial_offset,
data: ReaderData::File(Rc::new(RefCell::new(handle))),
})
}
}
impl<'a> From<&'a [u8]> for Reader<'a> {
fn from(value: &'a [u8]) -> Self {
Self {
data_len: value.len() as u64,
data_offset: 0,
data: ReaderData::BufferRef { buffer: value },
}
}
}
pub enum ReaderDataRef {
Vec(Vec<u8>),
FilePointer {
file: ReaderFileHandle,
offset: u64,
size: usize,
},
}
impl ReaderDataRef {
pub fn bytes(&self) -> Result<Vec<u8>> {
match self {
ReaderDataRef::Vec(slice) => Ok(slice.clone()),
ReaderDataRef::FilePointer { file, offset, size } => {
let mut file = file.borrow_mut();
let original_offset = file.seek(SeekFrom::Current(0))?;
let read_result = (|| {
file.seek(SeekFrom::Start(*offset))?;
let mut result = vec![0; *size];
file.read_exact(&mut result)?;
Ok(result)
})();
file.seek(SeekFrom::Start(original_offset))?;
read_result
}
}
}
}
#[cfg(test)]
mod test {
use super::*;
@@ -295,23 +148,27 @@ mod test {
#[test]
fn with_start_index_should_seek() {
let data: [u8; 8] = [1, 2, 3, 4, 5, 6, 7, 8];
let mut reader = Reader::from(&data as &[u8]);
let mut reader = Reader::new(&data);
assert_eq!(reader.get_u8().unwrap(), 1);
assert_eq!(reader.get_u8().unwrap(), 2);
assert_eq!(reader.get_u8().unwrap(), 3);
reader.seek(SeekFrom::Start(0)).unwrap();
assert_eq!(reader.get_u8().unwrap(), 1);
assert_eq!(reader.get_u8().unwrap(), 2);
assert_eq!(reader.get_u8().unwrap(), 3);
assert_eq!(reader.get_u8().unwrap(), 4);
reader.seek_relative(-3).unwrap();
assert_eq!(reader.get_u8().unwrap(), 2);
assert_eq!(reader.get_u8().unwrap(), 3);
reader.seek_relative(-2).unwrap();
assert_eq!(reader.get_u8().unwrap(), 2);
assert_eq!(reader.get_u8().unwrap(), 3);
{
let mut reader = reader.with_updated_bounds(0, 8).unwrap();
assert_eq!(reader.get_u8().unwrap(), 1);
assert_eq!(reader.get_u8().unwrap(), 2);
assert_eq!(reader.get_u8().unwrap(), 3);
assert_eq!(reader.get_u8().unwrap(), 4);
let mut reader = reader.with_updated_bounds(1, 7).unwrap();
assert_eq!(reader.get_u8().unwrap(), 2);
assert_eq!(reader.get_u8().unwrap(), 3);
let mut reader = reader.with_updated_bounds(1, 7).unwrap();
assert_eq!(reader.get_u8().unwrap(), 2);
assert_eq!(reader.get_u8().unwrap(), 3);
let reader = reader.with_updated_bounds(5, 7).unwrap();
assert_eq!(reader.remaining(), 2);
let reader = reader.with_updated_bounds(6, 6).unwrap();
assert_eq!(reader.remaining(), 0);
}
assert_eq!(reader.get_u8().unwrap(), 4);
}
}
@@ -2,6 +2,7 @@ use parser::Parser;
use parser_utils::errors::Error;
use std::{
env::{self, Args},
path::PathBuf,
process::exit,
};
@@ -14,18 +15,27 @@ pub fn main() {
}
};
let input_path_string = &config.input_file;
let input_path_string = &config.input_file.to_string_lossy();
eprintln!("Reading {}", input_path_string);
let data = match std::fs::read(&config.input_file) {
Ok(data) => data,
Err(error) => {
let error = format!("File read error: {error}");
print_help_text(&config.program_name, &error);
exit(2)
}
};
let mut parser = Parser::new();
if config.output_mode == OutputMode::Section {
let parsed_section = match parser.parse_section(input_path_string) {
let parsed_section = match parser.parse_section_from_data(&data, input_path_string) {
Ok(section) => section,
Err(error) => handle_parse_error(&config, error),
};
println!("{:#?}", parsed_section);
} else {
let parsed_onestore = match parser.parse_onestore_raw(input_path_string) {
let parsed_onestore = match parser.parse_onestore_raw(&data) {
Ok(section) => section,
Err(error) => handle_parse_error(&config, error),
};
@@ -62,7 +72,7 @@ enum OutputMode {
}
struct Config {
input_file: String,
input_file: PathBuf,
output_mode: OutputMode,
program_name: String,
}
@@ -76,7 +86,7 @@ impl Config {
});
};
let program_name = program_name.to_string();
let Some(input_file) = args.next() else {
let Some(input_file) = &args.next() else {
return Err(ConfigParseError {
reason: "Not enough arguments",
program_name,
@@ -103,7 +113,7 @@ impl Config {
}
Ok(Config {
input_file,
input_file: input_file.into(),
output_mode,
program_name,
})
@@ -13,7 +13,7 @@ pub(crate) struct BinaryItem(Vec<u8>);
impl BinaryItem {
pub(crate) fn parse(reader: Reader) -> Result<BinaryItem> {
let size = CompactU64::parse(reader)?.value();
let data = reader.read(size.try_into()?)?;
let data = reader.read(size as usize)?.to_vec();
Ok(BinaryItem(data))
}
@@ -25,9 +25,6 @@ impl BinaryItem {
impl From<BinaryItem> for FileBlob {
fn from(value: BinaryItem) -> Self {
let size = value.0.len();
let data = value.0;
FileBlob::new(Box::new(data), size)
value.0.into()
}
}
@@ -24,7 +24,8 @@ impl ObjectHeader {
/// Parse a 16-bit or 32-bit stream object header.
pub(crate) fn parse(reader: Reader) -> Result<ObjectHeader> {
let header_type = reader
.peek_u8()?
.bytes()
.first()
.ok_or(ErrorKind::UnexpectedEof("Reading ObjectHeader".into()))?;
match header_type & 0b11 {
@@ -198,7 +199,7 @@ impl ObjectHeader {
}
pub(crate) fn has_end_8(reader: Reader, object_type: ObjectType) -> Result<bool> {
let data = reader.peek_u8()?.ok_or(ErrorKind::UnexpectedEof(
let data = reader.bytes().first().ok_or(ErrorKind::UnexpectedEof(
"Reading ObjectHeader.has_end_8".into(),
))?;
@@ -42,7 +42,7 @@ impl DataElement {
let offset = CompactU64::parse(reader)?.value();
let length = CompactU64::parse(reader)?.value();
let data = reader.read(size as usize)?;
let data = reader.read(size as usize)?.to_vec();
let chunk_reference = DataElementFragmentChunkReference { offset, length };
let fragment = DataElementFragment {
@@ -22,7 +22,7 @@ impl ObjectDataBlob {
impl fmt::Debug for ObjectDataBlob {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ObjectDataBlob({} bytes)", self.0.len())
write!(f, "ObjectDataBlob({} bytes)", self.0.as_ref().len())
}
}
@@ -11,6 +11,7 @@ mod one;
mod onenote;
mod onestore;
mod shared;
mod utils;
pub use onenote::Parser;
@@ -1,5 +1,3 @@
use std::io::{Seek, SeekFrom};
use parser_utils::{errors::ErrorKind, errors::Result, parse::Parse};
/// See [\[MS-ONESTORE\] 2.2.4](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-onestore/0d86b13d-d58c-44e8-b931-4728b9d39a4b)
@@ -9,7 +7,10 @@ pub trait FileChunkReference {
fn data_location(&self) -> usize;
fn data_size(&self) -> usize;
fn seek_reader_to(&self, reader: &mut parser_utils::reader::Reader) -> Result<()> {
fn resolve_to_reader<'a>(
&self,
original_reader: &parser_utils::reader::Reader<'a>,
) -> Result<parser_utils::reader::Reader<'a>> {
if self.is_fcr_nil() {
return Err(ErrorKind::ResolutionFailed(
"Failed to resolve node reference -- is nil".into(),
@@ -17,8 +18,10 @@ pub trait FileChunkReference {
.into());
}
reader.seek(SeekFrom::Start(self.data_location() as u64))?;
Ok(())
original_reader.with_updated_bounds(
self.data_location(),
self.data_location() + self.data_size(),
)
}
}
@@ -56,15 +56,8 @@ impl FileNode {
)?),
2 => FileNodeDataRef::ElementList({
let list_ref = FileNodeChunkReference::parse(reader, stp_format, cb_format)?;
let reader_offset = reader.save_position();
let result = {
list_ref.seek_reader_to(reader)?;
FileNodeList::parse(reader, context, list_ref.data_size())
};
reader.restore_position(reader_offset)?;
result
let mut resolved_reader = list_ref.resolve_to_reader(reader)?;
FileNodeList::parse(&mut resolved_reader, context, list_ref.data_size())
}?),
_ => FileNodeDataRef::InvalidData,
};
@@ -169,7 +162,7 @@ impl FileNode {
0 => FileNodeData::Null,
other => {
log_warn!("Unknown node type: {:#0x}, size {}", other, size);
let size_used = (remaining_0 - remaining_1) as usize;
let size_used = remaining_0 - remaining_1;
assert!(size_used <= size);
let remaining_size = size - size_used;
FileNodeData::UnknownNode(UnknownNode::parse(reader, remaining_size)?)
@@ -177,7 +170,7 @@ impl FileNode {
};
let remaining_2 = reader.remaining();
let actual_size = (remaining_0 - remaining_2) as usize;
let actual_size = remaining_0 - remaining_2;
let node = Self {
node_type_id: node_id,
@@ -459,14 +452,9 @@ impl<RefSize: Parse> ParseWithRef for ObjectDeclarationWithSizedRefCount<RefSize
fn read_property_set(reader: Reader, property_set_ref: &FileNodeDataRef) -> Result<ObjectPropSet> {
match property_set_ref {
FileNodeDataRef::SingleElement(data_ref) => {
let reader_offset = reader.save_position();
let prop_set = {
data_ref.seek_reader_to(reader)?;
ObjectPropSet::parse(reader)
};
reader.restore_position(reader_offset)?;
prop_set
let mut prop_set_reader = data_ref.resolve_to_reader(reader)?;
let prop_set = ObjectPropSet::parse(&mut prop_set_reader)?;
Ok(prop_set)
}
FileNodeDataRef::ElementList(_) => Err(ErrorKind::MalformedOneStoreData(
"Expected a single element (reading PropertySet)".into(),
@@ -576,7 +564,7 @@ impl Parse for StringInStorageBuffer {
let characer_count = reader.get_u32()? as usize;
let string_size = characer_count * 2; // 2 bytes per character
let data = reader.read(string_size)?;
let data = (&data as &[u8]).utf16_to_string()?;
let data = data.utf16_to_string()?;
Ok(Self {
cch: characer_count,
data,
@@ -666,14 +654,10 @@ impl ParseWithRef for ObjectInfoDependencyOverridesFND {
fn parse(reader: parser_utils::Reader, obj_ref: &FileNodeDataRef) -> Result<Self> {
if let FileNodeDataRef::SingleElement(obj_ref) = obj_ref {
if !obj_ref.is_fcr_nil() {
let reader_offset = reader.save_position();
let data = {
obj_ref.seek_reader_to(reader)?;
ObjectInfoDependencyOverrideData::parse(reader)
};
reader.restore_position(reader_offset)?;
Ok(Self { data: data? })
let data = ObjectInfoDependencyOverrideData::parse(
&mut obj_ref.resolve_to_reader(reader)?,
)?;
Ok(Self { data })
} else {
Ok(Self {
data: ObjectInfoDependencyOverrideData::parse(reader)?,
@@ -721,15 +705,14 @@ pub struct FileData(pub FileBlob);
impl Debug for FileData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FileData(size={:} KiB)", self.0.len() / 1024)
write!(f, "FileData(size={:} KiB)", self.0.as_ref().len() / 1024)
}
}
impl ParseWithCount for FileData {
fn parse(reader: parser_utils::Reader, size: usize) -> Result<Self> {
let data_ref = FileBlob::new(Box::new(reader.as_data_ref(size)?), size);
reader.advance(size as u64)?;
Ok(FileData(data_ref))
let data = reader.read(size)?.to_vec();
Ok(FileData(data.into()))
}
}
@@ -744,15 +727,9 @@ impl ParseWithRef for FileDataStoreObjectReferenceFND {
fn parse(reader: parser_utils::Reader, data_ref: &FileNodeDataRef) -> Result<Self> {
let guid = Guid::parse(reader)?;
if let FileNodeDataRef::SingleElement(data_ref) = data_ref {
let reader_offset = reader.save_position();
let target = {
data_ref.seek_reader_to(reader)?;
FileDataStoreObject::parse(reader)
};
reader.restore_position(reader_offset)?;
let mut reader = data_ref.resolve_to_reader(reader)?;
Ok(Self {
target: target?,
target: FileDataStoreObject::parse(&mut reader)?,
guid,
})
} else {
@@ -968,7 +945,7 @@ pub struct UnknownNode {}
impl ParseWithCount for UnknownNode {
fn parse(reader: Reader, size: usize) -> Result<Self> {
reader.advance(size as u64)?;
reader.advance(size)?;
Ok(UnknownNode {})
}
}
@@ -20,9 +20,9 @@ impl FileNodeList {
let mut next_fragment_ref =
builder.add_fragment(FileNodeListFragment::parse(reader, context, size)?)?;
while !next_fragment_ref.is_fcr_nil() && !next_fragment_ref.is_fcr_zero() {
next_fragment_ref.seek_reader_to(reader)?;
let mut reader = next_fragment_ref.resolve_to_reader(reader)?;
let fragment =
FileNodeListFragment::parse(reader, context, next_fragment_ref.cb as usize)?;
FileNodeListFragment::parse(&mut reader, context, next_fragment_ref.cb as usize)?;
next_fragment_ref = builder.add_fragment(fragment)?;
}
Ok(Self {
@@ -49,13 +49,13 @@ impl FileNodeListFragment {
file_nodes.push(file_node);
}
assert_eq!(remaining_0 - reader.remaining(), file_node_size as u64);
assert_eq!(remaining_0 - reader.remaining(), file_node_size);
}
context.update_remaining_nodes_in_fragment(&header, maximum_node_count);
let padding_length = size - 36 - file_node_size;
reader.advance(padding_length as u64)?;
reader.advance(padding_length)?;
let next_fragment = FileChunkReference64x32::parse(reader)?;
@@ -77,8 +77,8 @@ impl Parse for OneStoreFile {
let mut free_chunk_list = Vec::new();
let mut free_chunk_ref = header.fcr_free_chunk_list.clone();
while !free_chunk_ref.is_fcr_nil() && !free_chunk_ref.is_fcr_zero() {
free_chunk_ref.seek_reader_to(reader)?;
let fragment = FreeChunkListFragment::parse(reader, free_chunk_ref.cb.into())?;
let mut reader = free_chunk_ref.resolve_to_reader(reader)?;
let fragment = FreeChunkListFragment::parse(&mut reader, free_chunk_ref.cb.into())?;
free_chunk_ref = fragment.fcr_next_chunk.clone();
free_chunk_list.push(fragment);
}
@@ -86,9 +86,10 @@ impl Parse for OneStoreFile {
let mut transaction_log = Vec::new();
let mut transaction_log_ref = header.fcr_transaction_log.clone();
loop {
transaction_log_ref.seek_reader_to(reader)?;
let mut reader = transaction_log_ref.resolve_to_reader(reader)?;
let fragment = TransactionLogFragment::parse(reader, transaction_log_ref.cb as usize)?;
let fragment =
TransactionLogFragment::parse(&mut reader, transaction_log_ref.cb as usize)?;
transaction_log_ref = fragment.next_fragment.clone();
transaction_log.push(fragment);
@@ -103,9 +104,9 @@ impl Parse for OneStoreFile {
let mut hashed_chunk_list = Vec::new();
let mut hash_chunk_ref = header.fcr_hashed_chunk_list.clone();
while !hash_chunk_ref.is_fcr_nil() && !hash_chunk_ref.is_fcr_zero() {
hash_chunk_ref.seek_reader_to(reader)?;
let mut reader = hash_chunk_ref.resolve_to_reader(reader)?;
let fragment = FileNodeListFragment::parse(
reader,
&mut reader,
&mut parse_context,
hash_chunk_ref.cb as usize,
)?;
@@ -116,8 +117,12 @@ impl Parse for OneStoreFile {
let file_node_list_root = &header.fcr_file_node_list_root;
let raw_file_node_list =
if !file_node_list_root.is_fcr_nil() && !file_node_list_root.is_fcr_zero() {
file_node_list_root.seek_reader_to(reader)?;
FileNodeList::parse(reader, &mut parse_context, file_node_list_root.cb as usize)?
let mut reader = file_node_list_root.resolve_to_reader(reader)?;
FileNodeList::parse(
&mut reader,
&mut parse_context,
file_node_list_root.cb as usize,
)?
} else {
FileNodeList::default()
};
@@ -13,7 +13,6 @@ pub(crate) struct Data {
pub(crate) offset_from_parent_vert: Option<f32>,
pub(crate) last_modified: Option<Time>,
pub(crate) ink_data: Option<ExGuid>,
pub(crate) children: Option<Vec<ExGuid>>,
pub(crate) ink_scaling_x: Option<f32>,
pub(crate) ink_scaling_y: Option<f32>,
}
@@ -27,16 +26,14 @@ pub(crate) fn parse(object: &Object) -> Result<Data> {
let offset_from_parent_horiz = simple::parse_f32(PropertyType::OffsetFromParentHoriz, object)?;
let offset_from_parent_vert = simple::parse_f32(PropertyType::OffsetFromParentVert, object)?;
let ink_data = ObjectReference::parse(PropertyType::InkData, object)?;
let children = ObjectReference::parse_vec(PropertyType::ContentChildNodes, object)?;
let ink_scaling_x = simple::parse_f32(PropertyType::InkScalingX, object)?;
let ink_scaling_y = simple::parse_f32(PropertyType::InkScalingY, object)?;
let ink_scaling_y = simple::parse_f32(PropertyType::InkScalingX, object)?;
let data = Data {
offset_from_parent_horiz,
offset_from_parent_vert,
last_modified,
ink_data,
children,
ink_scaling_x,
ink_scaling_y,
};
@@ -11,7 +11,7 @@ use parser_utils::errors::{ErrorKind, Result};
/// See [\[MS-ONE\] 2.2.32].
///
/// [\[MS-ONE\] 2.2.32]: https://docs.microsoft.com/en-us/openspecs/office_file_formats/ms-one/a665b5ad-ff40-4c0c-9e42-4b707254dc3f
#[derive(Clone, PartialEq, Debug)]
#[derive(Clone, PartialEq, PartialOrd, Debug)]
pub struct EmbeddedFile {
pub(crate) filename: String,
pub(crate) file_type: FileType,
@@ -46,8 +46,8 @@ impl EmbeddedFile {
}
/// The file's binary data.
pub fn data(&self) -> Result<Vec<u8>> {
self.data.load()
pub fn data(&self) -> &[u8] {
self.data.as_ref()
}
/// The max width of the embedded file's icon in half-inch increments.
@@ -1,3 +1,5 @@
use std::rc::Rc;
use crate::one::property::layout_alignment::LayoutAlignment;
use crate::one::property_set::{image_node, picture_container};
use crate::onenote::iframe::{IFrame, parse_iframe};
@@ -12,7 +14,7 @@ use parser_utils::errors::{ErrorKind, Result};
/// See [\[MS-ONE\] 2.2.24].
///
/// [\[MS-ONE\] 2.2.24]: https://docs.microsoft.com/en-us/openspecs/office_file_formats/ms-one/b7bb4d1a-2a57-4819-9eb4-5a2ce8cf210f
#[derive(Clone, PartialEq, Debug)]
#[derive(Clone, PartialEq, PartialOrd, Debug)]
pub struct Image {
pub(crate) data: Option<FileBlob>,
pub(crate) extension: Option<String>,
@@ -52,8 +54,8 @@ impl Image {
/// The image's binary data.
///
/// If `None` this means that the image data hasn't been uploaded yet.
pub fn data(&self) -> Result<Option<Vec<u8>>> {
self.data.as_ref().map(|data| data.load()).transpose()
pub fn data(&self) -> Option<Rc<Vec<u8>>> {
self.data.as_ref().map(|data| data.0.clone())
}
/// The image's file extension.
@@ -9,34 +9,17 @@ use parser_utils::log_warn;
/// An ink object.
#[derive(Clone, Debug)]
pub struct Ink {
pub(crate) content: InkContent,
pub(crate) ink_strokes: Vec<InkStroke>,
pub(crate) bounding_box: Option<InkBoundingBox>,
pub(crate) offset_horizontal: Option<f32>,
pub(crate) offset_vertical: Option<f32>,
}
#[derive(Clone, Debug)]
pub enum InkContent {
InkGroup(Vec<Ink>),
Strokes(Vec<InkStroke>),
}
impl Ink {
/// The ink strokes contained in this ink object.
pub fn ink_strokes(&self) -> &[InkStroke] {
match &self.content {
InkContent::InkGroup(_ink) => &[],
InkContent::Strokes(strokes) => strokes,
}
}
/// Groups contained within this group
pub fn child_groups(&self) -> &[Ink] {
match &self.content {
InkContent::InkGroup(ink) => ink,
InkContent::Strokes(_strokes) => &[],
}
&self.ink_strokes
}
/// The ink object's bounding box.
@@ -180,62 +163,24 @@ impl InkBoundingBox {
width: self.width * factor,
}
}
fn union(&self, other: Option<InkBoundingBox>) -> InkBoundingBox {
let Some(other) = other else {
return *self;
};
let x = self.x.min(other.x);
let y = self.y.min(other.y);
let x2 = (self.x + self.width).max(other.x + other.width);
let y2 = (self.y + self.height).max(other.y + other.height);
InkBoundingBox {
x,
y,
height: y2 - y,
width: x2 - x,
}
}
}
pub(crate) fn parse_ink(ink_container_id: ExGuid, space: ObjectSpaceRef) -> Result<Ink> {
parse_ink_rec(ink_container_id, space, 0)
}
fn parse_ink_rec(ink_container_id: ExGuid, space: ObjectSpaceRef, depth: u32) -> Result<Ink> {
// Cut maximum recursion depth at 16 to guard against cycles (e.g. if an ink node is declared
// to recursively contain itself). An explicit error should be easier to debug than the
// stack overflow that would otherwise occur.
if depth > 16 {
return Err(
parser_error!(MalformedOneStoreData, "Maximum ink nesting depth exceeded").into(),
);
}
let container_object = space
.get_object(ink_container_id)
.ok_or_else(|| ErrorKind::MalformedOneNoteData("ink container is missing".into()))?;
let container = ink_container::parse(&container_object)?;
let Some(ink_data_id) = container.ink_data else {
let Some(children) = container.children else {
let ink_data_id = match container.ink_data {
Some(id) => id,
None => {
return Ok(Ink {
content: InkContent::Strokes(vec![]),
ink_strokes: vec![],
bounding_box: None,
offset_horizontal: None,
offset_vertical: None,
offset_horizontal: container.offset_from_parent_horiz,
offset_vertical: container.offset_from_parent_vert,
});
};
let (bbox, content) = parse_ink_group(children, space, depth)?;
return Ok(Ink {
bounding_box: bbox,
offset_horizontal: container.offset_from_parent_horiz,
offset_vertical: container.offset_from_parent_vert,
content,
});
}
};
let (ink_strokes, bounding_box) = parse_ink_data(
@@ -246,36 +191,13 @@ fn parse_ink_rec(ink_container_id: ExGuid, space: ObjectSpaceRef, depth: u32) ->
)?;
Ok(Ink {
content: InkContent::Strokes(ink_strokes),
ink_strokes,
bounding_box,
offset_horizontal: container.offset_from_parent_horiz,
offset_vertical: container.offset_from_parent_vert,
})
}
fn parse_ink_group(
children: Vec<ExGuid>,
space: ObjectSpaceRef,
recursion_depth: u32,
) -> Result<(Option<InkBoundingBox>, InkContent)> {
let mut ink_contents = vec![];
let children = children
.into_iter()
.map(|group_id| parse_ink_rec(group_id, space.clone(), recursion_depth + 1));
let mut bbox = None;
for child in children {
let child = child?;
if let Some(child_bbox) = &child.bounding_box {
bbox = Some(child_bbox.union(bbox));
}
ink_contents.push(child);
}
Ok((bbox, InkContent::InkGroup(ink_contents)))
}
pub(crate) fn parse_ink_data(
ink_data_id: ExGuid,
space: ObjectSpaceRef,
@@ -39,8 +39,8 @@ impl Parser {
/// sections from the folder that the table of contents file is in.
pub fn parse_notebook(&mut self, path: String) -> Result<Notebook> {
log!("Parsing notebook: {:?}", path);
let data = fs_driver().open_file(&path)?;
let store = parse_onestore(&mut Reader::try_from(data)?)?;
let data = fs_driver().read_file(&path)?;
let store = parse_onestore(&mut Reader::new(&data))?;
if store.get_type() != OneStoreType::TableOfContents {
return Err(ErrorKind::NotATocFile { file: path }.into());
@@ -55,7 +55,7 @@ impl Parser {
.map(|p| {
let is_dir = fs_driver().is_directory(&p)?;
if !is_dir {
self.parse_section(&p).map(SectionEntry::Section)
self.parse_section(p).map(SectionEntry::Section)
} else {
self.parse_section_group(p).map(SectionEntry::SectionGroup)
}
@@ -69,28 +69,22 @@ impl Parser {
///
/// The `path` argument must point to a `.one` file that contains a
/// OneNote section.
pub fn parse_section(&mut self, path: &str) -> Result<Section> {
pub fn parse_section(&mut self, path: String) -> Result<Section> {
log!("Parsing section: {:?}", path);
let file = fs_driver().open_file(path)?;
self.parse_section_from_reader(Reader::try_from(file)?, path)
let data = fs_driver().read_file(path.as_str())?;
self.parse_section_from_data(&data, &path)
}
/// Parses low-level OneStore data. Exported for debugging purposes.
pub fn parse_onestore_raw(&mut self, path: &str) -> Result<Rc<dyn OneStore>> {
log!("Parsing OneStore: {:?}", path);
let file = fs_driver().open_file(path)?;
parse_onestore(&mut Reader::try_from(file)?)
/// Parses low-level OneStore data
pub fn parse_onestore_raw(&mut self, data: &[u8]) -> Result<Rc<dyn OneStore>> {
parse_onestore(&mut Reader::new(data))
}
/// Parse a OneNote section file from a byte array.
/// The [path] is used to provide debugging information and determine
/// the name of the section file.
pub fn parse_section_from_data(&mut self, data: &[u8], path: &str) -> Result<Section> {
self.parse_section_from_reader(Reader::from(data), path)
}
fn parse_section_from_reader(&mut self, mut reader: Reader, path: &str) -> Result<Section> {
let store = parse_onestore(&mut reader)?;
let store = parse_onestore(&mut Reader::new(data))?;
if store.get_type() != OneStoreType::Section {
return Err(ErrorKind::NotASectionFile {
@@ -5,7 +5,7 @@ use crate::one::property::color_ref::ColorRef;
use crate::one::property::layout_alignment::LayoutAlignment;
use crate::one::property::paragraph_alignment::ParagraphAlignment;
use crate::one::property_set::{embedded_ink_container, paragraph_style_object, rich_text_node};
use crate::onenote::ink::{Ink, InkBoundingBox, InkContent, parse_ink_data};
use crate::onenote::ink::{Ink, InkBoundingBox, parse_ink_data};
use crate::onenote::note_tag::{NoteTag, parse_note_tags};
use crate::onenote::text_region::TextRegion;
use crate::onestore::object::Object;
@@ -423,45 +423,32 @@ pub(crate) fn parse_rich_text(content_id: ExGuid, space: ObjectSpaceRef) -> Resu
let embedded_objects: Vec<_> = objects
.into_iter()
.enumerate()
.map(|(i, (object_type, embedded_data))| {
let i = i - objects_without_ref;
let object_ref = data.text_run_data_object.get(i);
let is_valid_ref = object_ref
.map(|object_ref| space.get_object(*object_ref).is_some())
.unwrap_or(true);
// Based on sample .one files, spaces and EOL blobs either:
// - Have an invalid associated object reference (is_valid_ref = false)
// - Have no associated object reference (is_valid_ref = true)
//
// In the first case, the object reference will be skipped automatically.
// In the second, we need to adjust so that the references for subsequent
// objects aren't skipped.
if let Some(object_type) = object_type
&& is_valid_ref
&& (object_type == INK_END_OF_LINE_BLOB || object_type == INK_SPACE_BLOB)
{
.map(|(i, (object_type, embedded_data))| match object_type {
Some(INK_END_OF_LINE_BLOB) => {
objects_without_ref += 1;
Ok(Some(EmbeddedObject::InkLineBreak))
}
match object_type {
Some(INK_END_OF_LINE_BLOB) => Ok(Some(EmbeddedObject::InkLineBreak)),
Some(INK_SPACE_BLOB) => parse_embedded_ink_space(embedded_data)
.map(|space| Some(EmbeddedObject::InkSpace(space))),
None => {
if let Some(object_ref) = object_ref {
return parse_embedded_ink_data(*object_ref, space.clone(), embedded_data)
.map(|container| Some(EmbeddedObject::Ink(container)));
}
Ok(None)
Some(INK_SPACE_BLOB) => {
objects_without_ref += 1;
parse_embedded_ink_space(embedded_data)
.map(|space| Some(EmbeddedObject::InkSpace(space)))
}
None => {
if !data.text_run_data_object.is_empty() {
return parse_embedded_ink_data(
data.text_run_data_object[i - objects_without_ref],
space.clone(),
embedded_data,
)
.map(|container| Some(EmbeddedObject::Ink(container)));
}
Some(v) => Err(ErrorKind::MalformedOneNoteFileData(
format!("unknown embedded object type: {:x}", v).into(),
)
.into()),
Ok(None)
}
Some(v) => Err(ErrorKind::MalformedOneNoteFileData(
format!("unknown embedded object type: {:x}", v).into(),
)
.into()),
})
.collect::<Result<Vec<_>>>()?
.into_iter()
@@ -526,7 +513,7 @@ fn parse_embedded_ink_data(
let data = EmbeddedInkContainer {
ink: Ink {
content: InkContent::Strokes(strokes),
ink_strokes: strokes,
bounding_box: bb,
offset_horizontal: data.offset_horiz,
offset_vertical: data.offset_vert,
@@ -3,7 +3,7 @@
//!
//! Provides interfaces that are implemented by the different OneStore parsers.
use std::{io::Seek, io::SeekFrom, rc::Rc};
use std::rc::Rc;
use crate::{
fsshttpb_onestore::{self, packaging::OneStorePackaging},
@@ -39,16 +39,16 @@ pub fn parse_onestore<'a>(reader: &mut Reader<'a>) -> Result<Rc<dyn OneStore>> {
// Try parsing as the standard format first.
// Clone the reader to save the original offset. When retrying parsing with
// a different format, parsing should start from the same location.
reader.seek(SeekFrom::Start(0))?;
let onestore_local = OneStoreFile::parse(reader);
let mut reader_1 = reader.clone();
let onestore_local = OneStoreFile::parse(&mut reader_1);
match onestore_local {
Ok(onestore) => Ok(Rc::new(onestore)),
Err(Error {
kind: ErrorKind::NotLocalOneStore(_),
}) => {
reader.seek(SeekFrom::Start(0))?;
let packaging = OneStorePackaging::parse(reader)?;
let mut reader_2 = reader.clone();
let packaging = OneStorePackaging::parse(&mut reader_2)?;
let store = fsshttpb_onestore::parse_store(&packaging)?;
Ok(Rc::new(store))
}
@@ -1,5 +1,5 @@
use parser_utils::Reader;
use parser_utils::errors::Result;
use parser_utils::errors::{ErrorKind, Result};
/// A compact unsigned 64-bit integer.
///
@@ -21,63 +21,94 @@ impl CompactU64 {
}
pub(crate) fn parse(reader: Reader) -> Result<CompactU64> {
let first_byte = reader.get_u8()?;
let bytes = reader.bytes();
let first_byte = bytes.first().copied().ok_or(ErrorKind::UnexpectedEof(
"Reading CompactU64 (first byte)".into(),
))?;
if first_byte == 0 {
reader.advance(1)?;
return Ok(CompactU64(0));
}
if first_byte & 1 != 0 {
return Ok(CompactU64((first_byte >> 1) as u64));
return Ok(CompactU64((reader.get_u8()? >> 1) as u64));
}
if first_byte & 2 != 0 {
let second_byte = reader.get_u8()?;
let value = u16::from_le_bytes([first_byte, second_byte]);
return Ok(CompactU64((value >> 2) as u64));
return Ok(CompactU64((reader.get_u16()? >> 2) as u64));
}
if first_byte & 4 != 0 {
let bytes = reader.read(2)?;
let value = u32::from_le_bytes([first_byte, bytes[0], bytes[1], 0]);
if reader.remaining() < 3 {
return Err(ErrorKind::UnexpectedEof("Reading CompactU64".into()).into());
}
let value = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], 0]);
reader.advance(3)?;
return Ok(CompactU64((value >> 3) as u64));
}
if first_byte & 8 != 0 {
let bytes = reader.read(3)?;
let value = u32::from_le_bytes([first_byte, bytes[0], bytes[1], bytes[2]]);
if reader.remaining() < 4 {
return Err(ErrorKind::UnexpectedEof("CompactU64".into()).into());
}
let value = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
reader.advance(4)?;
return Ok(CompactU64((value >> 4) as u64));
}
if first_byte & 16 != 0 {
let bytes = reader.read(4)?;
if reader.remaining() < 5 {
return Err(ErrorKind::UnexpectedEof("CompactU64".into()).into());
}
let value =
u64::from_le_bytes([first_byte, bytes[0], bytes[1], bytes[2], bytes[3], 0, 0, 0]);
u64::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], 0, 0, 0]);
reader.advance(5)?;
return Ok(CompactU64(value >> 5));
}
if first_byte & 32 != 0 {
let bytes = reader.read(5)?;
if reader.remaining() < 6 {
return Err(ErrorKind::UnexpectedEof("CompactU64".into()).into());
}
let value = u64::from_le_bytes([
first_byte, bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], 0, 0,
first_byte, bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], 0, 0,
]);
reader.advance(6)?;
return Ok(CompactU64(value >> 6));
}
if first_byte & 64 != 0 {
let bytes = reader.read(6)?;
if reader.remaining() < 7 {
return Err(ErrorKind::UnexpectedEof("CompactU64".into()).into());
}
let value = u64::from_le_bytes([
first_byte, bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], 0,
first_byte, bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], 0,
]);
reader.advance(7)?;
return Ok(CompactU64(value >> 7));
}
if first_byte & 128 != 0 {
reader.advance(1)?;
return Ok(CompactU64(reader.get_u64()?));
}
@@ -91,42 +122,76 @@ mod test {
use parser_utils::reader::Reader;
#[test]
fn should_parse_from_reader() {
for (input, expected) in [
// Zero case
(vec![0u8], 0),
// 7-bit case
(vec![0xF], 7),
// 14-bit case
(vec![0xFE, 0x0], 0x3F),
// 21-bit case
(vec![0xd4, 0x8b, 0x10], 135546),
// 28-bit case
(vec![0xd8, 0x8b, 0x10, 0x10], 0x10108bd),
// 35-bit case
(vec![0x10, 0x8b, 0x10, 0x13, 0x10], 0x1013108b0 >> 1),
// 42-bit case
(vec![0x20, 0x8b, 0x10, 0x13, 0x10, 0x10], 0x101013108b0 >> 2),
// 49-bit case
(
vec![0x40, 0x8b, 0x10, 0x13, 0x10, 0x10, 0x14],
0x14101013108b0 >> 3,
),
// 64-bit case
(
vec![0x80, 0x8b, 0x10, 0x13, 0x10, 0x10, 0x14, 0x10, 0x14],
0x141014101013108b,
),
] {
let test_label = format!("{input:?} should parse to 0x{expected:0x}");
assert_eq!(
CompactU64::parse(&mut Reader::from(&input as &[u8]))
.expect(&test_label)
.value(),
expected,
"{}",
test_label,
);
}
fn test_zero() {
assert_eq!(
CompactU64::parse(&mut Reader::new(&[0u8])).unwrap().value(),
0
);
}
#[test]
fn test_7_bit() {
assert_eq!(
CompactU64::parse(&mut Reader::new(&[0u8])).unwrap().value(),
0
);
}
#[test]
fn test_14_bit() {
assert_eq!(
CompactU64::parse(&mut Reader::new(&[0u8])).unwrap().value(),
0
);
}
#[test]
fn test_21_bit() {
assert_eq!(
CompactU64::parse(&mut Reader::new(&[0xd4u8, 0x8b, 0x10]))
.unwrap()
.value(),
135546
);
}
#[test]
fn test_28_bit() {
assert_eq!(
CompactU64::parse(&mut Reader::new(&[0u8])).unwrap().value(),
0
);
}
#[test]
fn test_35_bit() {
assert_eq!(
CompactU64::parse(&mut Reader::new(&[0u8])).unwrap().value(),
0
);
}
#[test]
fn test_42_bit() {
assert_eq!(
CompactU64::parse(&mut Reader::new(&[0u8])).unwrap().value(),
0
);
}
#[test]
fn test_49_bit() {
assert_eq!(
CompactU64::parse(&mut Reader::new(&[0u8])).unwrap().value(),
0
);
}
#[test]
fn test_64_bit() {
assert_eq!(
CompactU64::parse(&mut Reader::new(&[0u8])).unwrap().value(),
0
);
}
}
@@ -1,67 +1,30 @@
use std::fmt::Debug;
use std::rc::Rc;
use parser_utils::Result;
use parser_utils::reader::ReaderDataRef;
#[derive(Clone)]
pub struct FileBlob {
// File blobs can be huge. Lazy-load the data and only keep it as long
// as necessary.
loader: Rc<dyn FileDataLoader>,
size: usize,
}
impl PartialEq for FileBlob {
fn eq(&self, other: &Self) -> bool {
self.size == other.size && Rc::ptr_eq(&self.loader, &other.loader)
}
}
pub trait FileDataLoader {
fn load(&self) -> Result<Vec<u8>>;
}
impl FileDataLoader for Vec<u8> {
fn load(&self) -> Result<Vec<u8>> {
Ok(self.clone())
}
}
impl FileDataLoader for ReaderDataRef {
fn load(&self) -> Result<Vec<u8>> {
self.bytes()
}
}
impl Default for FileBlob {
fn default() -> Self {
Self {
loader: Rc::new(vec![]),
size: 0,
}
}
}
#[derive(Clone, Default, Eq, PartialEq, PartialOrd)]
pub struct FileBlob(pub Rc<Vec<u8>>);
impl Debug for FileBlob {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FileBlob [ {:?} KiB ]", self.size / 1024)
let len = self.0.len();
write!(f, "FileBlob [ {:?} KiB ]", len / 1024)
}
}
impl FileBlob {
pub fn new(on_load: Box<dyn FileDataLoader>, size: usize) -> Self {
Self {
loader: on_load.into(),
size,
}
}
pub fn len(&self) -> usize {
self.size
}
pub fn load(&self) -> Result<Vec<u8>> {
self.loader.load()
pub fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl From<Vec<u8>> for FileBlob {
fn from(value: Vec<u8>) -> Self {
Self(Rc::new(value))
}
}
impl From<&[u8]> for FileBlob {
fn from(value: &[u8]) -> Self {
Self(Rc::new(Vec::from(value)))
}
}
@@ -169,7 +169,7 @@ impl PropertyValue {
fn parse_vec(reader: Reader) -> Result<PropertyValue> {
let size = reader.get_u32()?;
let data = reader.read(size as usize)?;
let data = reader.read(size as usize)?.to_vec();
Ok(PropertyValue::Vec(data))
}
@@ -0,0 +1,66 @@
use itertools::Itertools;
use std::collections::HashMap;
use std::fmt;
use std::fmt::Display;
pub(crate) struct AttributeSet(HashMap<&'static str, String>);
#[allow(dead_code)]
impl AttributeSet {
pub(crate) fn new() -> Self {
Self(HashMap::new())
}
pub(crate) fn set(&mut self, attribute: &'static str, value: String) {
self.0.insert(attribute, value);
}
}
impl Display for AttributeSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
self.0
.iter()
.sorted_by(|(a, _), (b, _)| Ord::cmp(a, b))
.map(|(attr, value)| attr.to_string() + "=\"" + value + "\"")
.join(" ")
)
}
}
#[derive(Debug, Clone)]
pub(crate) struct StyleSet(HashMap<&'static str, String>);
#[allow(dead_code)]
impl StyleSet {
pub(crate) fn new() -> Self {
Self(HashMap::new())
}
pub(crate) fn set(&mut self, prop: &'static str, value: String) {
self.0.insert(prop, value);
}
pub(crate) fn extend(&mut self, other: Self) {
self.0.extend(other.0)
}
pub(crate) fn len(&self) -> usize {
self.0.len()
}
}
impl Display for StyleSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
self.0
.iter()
.sorted_by(|(a, _), (b, _)| Ord::cmp(a, b))
.map(|(attr, value)| attr.to_string() + ": " + value + ";")
.join(" ")
)
}
}
@@ -47,7 +47,7 @@ pub fn convert(path: &str, output_dir: &str, base_path: &str) -> Result<()> {
return Ok(());
}
let section = Parser::new().parse_section(path)?;
let section = Parser::new().parse_section(path.to_owned())?;
let section_output_dir = fs_driver().get_output_path(base_path, output_dir, path);
section::Renderer::new().render(&section, section_output_dir.to_owned())?;
@@ -1,7 +1,4 @@
use crate::{
page::Renderer,
utils::{StyleSet, html_entities},
};
use crate::{page::Renderer, utils::StyleSet};
use color_eyre::Result;
use parser::contents::EmbeddedFile;
use parser::property::embedded_file::FileType;
@@ -14,7 +11,7 @@ impl<'a> Renderer<'a> {
.to_unique_safe_filename(&self.output, file.filename())?;
let path = fs_driver().join(&self.output, &filename);
log!("Rendering embedded file: {:?}", path);
fs_driver().write_file(&path, &file.data()?)?;
fs_driver().write_file(&path, file.data())?;
let mut styles = StyleSet::new();
if let Some(offset_x_half_inches) = file.offset_horizontal() {
@@ -38,8 +35,7 @@ impl<'a> Renderer<'a> {
styles.set("line-height", "17px".into());
let style_attr = styles.to_html_attr();
let escaped_filename = html_entities(&filename);
format!("<p {style_attr}><a href=\"{escaped_filename}\">{escaped_filename}</a></p>")
format!("<p {style_attr}><a href=\"{filename}\">{filename}</a></p>")
}
};
@@ -8,7 +8,7 @@ impl<'a> Renderer<'a> {
pub(crate) fn render_image(&mut self, image: &Image) -> Result<String> {
let mut content = String::new();
if let Some(data) = image.data()? {
if let Some(data) = image.data() {
let filename = self.determine_image_filename(image)?;
let path = fs_driver().join(&self.output, &filename);
log!("Rendering image: {:?}", path);
@@ -20,7 +20,7 @@ impl<'a> Renderer<'a> {
attrs.set("src", filename);
if let Some(text) = image.alt_text() {
attrs.set("alt", text.to_string());
attrs.set("alt", text.to_string().replace('"', "&quot;"));
}
if let Some(width) = image.layout_max_width() {
@@ -34,11 +34,6 @@ impl InkBuilder {
}
pub(crate) fn push(&mut self, ink: &Ink, display_bounding_box: Option<&InkBoundingBox>) {
let children = ink.child_groups();
for child in children {
self.push(child, ink.bounding_box().as_ref().or(display_bounding_box));
}
let strokes = ink.ink_strokes();
if strokes.is_empty() {
return;
@@ -37,7 +37,7 @@ impl Display for AttributeSet {
self.0
.iter()
.sorted_by(|(a, _), (b, _)| Ord::cmp(a, b))
.map(|(attr, value)| attr.to_string() + "=\"" + &html_entities(value) + "\"")
.map(|(attr, value)| attr.to_string() + "=\"" + value + "\"")
.join(" ")
)
}
@@ -117,7 +117,7 @@ pub(crate) fn url_encode(url: &str) -> String {
#[cfg(test)]
mod test {
use crate::utils::{AttributeSet, url_encode};
use crate::utils::url_encode;
use super::html_entities;
@@ -139,14 +139,4 @@ mod test {
"http://example.com/%22"
);
}
#[test]
fn should_build_html_attributes() {
let mut attrs = AttributeSet::new();
attrs.set("style", "font-family: \"Multi-word font\";".to_string());
assert_eq!(
format!("{}", attrs),
"style=\"font-family: &quot;Multi-word font&quot;;\""
);
}
}
+1 -3
View File
@@ -262,6 +262,4 @@ bgcolor
bordercolor
togglefullscreen
onestore
macshot
pdate
coderabbitai
pdate
+3 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: 2026-01-09 17:09+0100\n"
"Last-Translator: summoner <summoner@disroot.org>\n"
"Last-Translator: summoner <summoner@vivaldi.net>\n"
"Language-Team: Hungarian <>\n"
"Language: hu_HU\n"
"MIME-Version: 1.0\n"
@@ -1998,7 +1998,7 @@ msgstr "Módosítások elvetése"
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:166
#: packages/app-mobile/root.tsx:783
msgid "Dismiss"
msgstr "Eltüntetés"
msgstr "Elvetés"
#: packages/app-cli/app/command-geoloc.ts:13
msgid "Displays a geolocation URL for the note."
@@ -2868,7 +2868,7 @@ msgstr "Kényszerített elérési út stílusa"
#: packages/lib/commands/historyForward.ts:6
msgid "Forward"
msgstr "Előre"
msgstr "Továbbítás"
#: packages/app-cli/app/command-import.ts:53
#: packages/app-desktop/gui/ImportScreen.tsx:89
File diff suppressed because it is too large Load Diff
+103 -101
View File
@@ -1,15 +1,14 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR Laurent Cozic
# This file is distributed under the same license as the Joplin-CLI package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
# Previous-Translator: Титан <fignin@ya.ru>"
# Last-Translator: Dmitriy Q <atsip-help@yandex.ru>
#
msgid ""
msgstr ""
"Project-Id-Version: Joplin-CLI 3.2.13\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Last-Translator: Dmitriy Q <atsip-help@yandex.ru>\n"
"Last-Translator: Arman Saga <asagatbekov@gmail.com>\n"
"Language-Team: Sergey Segeda <thesermanarm@gmail.com>\n"
"Language: ru_RU\n"
"MIME-Version: 1.0\n"
@@ -17,7 +16,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"X-Generator: Poedit 3.8\n"
"X-Generator: Poedit 3.7\n"
#: packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx:621
msgid "- Camera: to allow taking a picture and attaching it to a note."
@@ -309,7 +308,7 @@ msgstr ""
#: packages/editor/ProseMirror/plugins/imagePlugin.ts:148
msgid "A brief description of the image:"
msgstr "Краткое описание изображения:"
msgstr ""
#: packages/lib/models/settings/builtInMetadata.ts:1993
msgid ""
@@ -337,7 +336,7 @@ msgstr "A5"
#: packages/lib/models/settings/builtInMetadata.ts:1121
msgid "ABC musical notation: Options"
msgstr "Музыкальная нотация ABC: параметры"
msgstr ""
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.tsx:62
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.tsx:240
@@ -433,7 +432,7 @@ msgstr "Добавить текст заметки"
#: packages/editor/ProseMirror/plugins/tablePlugin.ts:31
msgid "Add column"
msgstr "Добавить столбец"
msgstr ""
#: packages/app-mobile/components/buttons/FloatingActionButton.tsx:66
#: packages/app-mobile/components/ComboBox.tsx:103
@@ -450,8 +449,9 @@ msgid "Add recipient:"
msgstr "Добавить получателя:"
#: packages/editor/ProseMirror/plugins/tablePlugin.ts:26
#, fuzzy
msgid "Add row"
msgstr "Добавить строку"
msgstr "Добавить новый"
#: packages/app-mobile/components/TagEditor.tsx:281
msgid "Add tags:"
@@ -591,9 +591,6 @@ msgid ""
"offline or cannot connect to the server.\n"
"Error: %s"
msgstr ""
"Произошла ошибка при отправке ответа. Это может произойти, если приложение "
"находится в оффлайн-режиме или не может подключиться к серверу.\n"
"Ошибка: %s"
#: packages/app-desktop/ElectronAppWrapper.ts:190
msgid "An error occurred: %s"
@@ -810,7 +807,7 @@ msgstr "Бета"
#: packages/app-mobile/components/NoteEditor/commandDeclarations.ts:123
msgid "Block code"
msgstr "Блок кода"
msgstr ""
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:55
#: packages/app-mobile/components/NoteEditor/commandDeclarations.ts:80
@@ -928,8 +925,9 @@ msgid "Cannot change encrypted item"
msgstr "Невозможно изменить зашифрованный элемент"
#: packages/lib/commands/convertNoteToMarkdown.ts:42
#, fuzzy
msgid "Cannot convert read-only item: \"%s\""
msgstr "Невозможно преобразовать только для чтения элемент: \"%s\""
msgstr "Невозможно создать новую заметку: %s"
#: packages/lib/models/Note.ts:622
msgid "Cannot copy note to \"%s\" notebook"
@@ -1088,7 +1086,7 @@ msgstr "Проверка... Пожалуйста, подождите."
#: packages/app-desktop/gui/NoteContentPropertiesDialog.tsx:114
msgid "Chinese/Japanese/Korean characters"
msgstr "Китайские/японские/корейские иероглифы"
msgstr ""
#: packages/app-mobile/components/screens/Note/commands/attachFile.ts:98
msgid "Choose an option"
@@ -1220,8 +1218,9 @@ msgstr "Создать новый блокнот"
#: packages/app-mobile/components/screens/Note/Note.tsx:1758
#: packages/app-mobile/components/screens/NoteRevisionViewer.tsx:225
#, fuzzy
msgid "Collapse title"
msgstr "Свернуть заголовок"
msgstr "Крах %s"
#: packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.tsx:23
msgid "Collapsed"
@@ -1254,7 +1253,7 @@ msgstr "Команда"
#: packages/app-cli/app/command-keymap.ts:30
msgid "COMMAND"
msgstr "КОММАНДА"
msgstr ""
#: packages/app-desktop/plugins/GotoAnything.tsx:783
msgid "Command palette"
@@ -1319,8 +1318,9 @@ msgid "Configuration"
msgstr "Конфигурация"
#: packages/app-cli/app/command-keymap.ts:24
#, fuzzy
msgid "Configured keyboard shortcuts:"
msgstr "Настроенные сочетания клавиш:"
msgstr "Сочетания клавиш"
#: packages/lib/models/settings/builtInMetadata.ts:1296
msgid "Configures the size of scrollbars used in the app."
@@ -1396,8 +1396,9 @@ msgid "Convert it"
msgstr "Преобразовать в заметку"
#: packages/lib/commands/convertNoteToMarkdown.ts:18
#, fuzzy
msgid "Convert to Markdown"
msgstr "Преобразовать в Markdown"
msgstr "Преобразовать в задачу"
#: packages/app-mobile/components/screens/Note/Note.tsx:1350
msgid "Convert to note"
@@ -1503,8 +1504,9 @@ msgid "Could not connect to plugin repository."
msgstr "Не удалось соединиться с репозиторием плагинов."
#: packages/lib/commands/convertNoteToMarkdown.ts:70
#, fuzzy
msgid "Could not convert notes to Markdown: %s"
msgstr "Не удалось преобразовать заметки в Markdown: %s"
msgstr "Не удалось экспортировать заметки: %s"
#: packages/app-desktop/InteropServiceHelper.ts:235
msgid "Could not export notes: %s"
@@ -1560,7 +1562,7 @@ msgstr "Создать новый блокнот"
#: packages/app-mobile/components/FolderPicker.tsx:112
msgid "Create new notebook"
msgstr "Создать новый блокнот"
msgstr "Создает новый блокнот."
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.ts:9
#: packages/app-mobile/components/ProfileSwitcher/ProfileEditor.tsx:88
@@ -1616,7 +1618,7 @@ msgstr "Создано: %s"
#: packages/app-mobile/components/screens/Notes/NewNoteButton.tsx:70
msgid "Creates a new note with an attachment of type %s"
msgstr "Создать новый блокнот с вложением типа %s"
msgstr "Создать новый блокнот под родительским блокнотом."
#: packages/app-cli/app/command-mknote.js:12
msgid "Creates a new note."
@@ -1775,8 +1777,9 @@ msgid "Delete attachment \"%s\"?"
msgstr "Удалить вложение \"%s\"?"
#: packages/editor/ProseMirror/plugins/tablePlugin.ts:41
#, fuzzy
msgid "Delete column"
msgstr "Удалить столбец"
msgstr "Удалить строку"
#: packages/server/src/services/TaskService.ts:27
msgid "Delete expired sessions"
@@ -1817,18 +1820,18 @@ msgid "Delete profile \"%s\""
msgstr "Удалить профиль \"%s\""
#: packages/app-desktop/gui/ProfileEditor.tsx:147
#, fuzzy
msgid ""
"Delete profile \"%s\"?\n"
"\n"
"All data, including notes, notebooks and tags will be permanently deleted."
msgstr ""
"Удалить профиль \"%s\"?\n"
"\n"
"Все данные, включая заметки, блокноты и теги, будут безвозвратно удалены."
"Все данные, включая заметки, блокноты и теги, будут удалены безвозвратно."
#: packages/editor/ProseMirror/plugins/tablePlugin.ts:36
#, fuzzy
msgid "Delete row"
msgstr "Удалить строку"
msgstr "Удалить заметку"
#: packages/app-mobile/components/ScreenHeader/index.tsx:487
msgid "Delete selected notes"
@@ -1841,10 +1844,6 @@ msgid ""
"All notes associated with this tag will remain, but the tag will be removed "
"from all notes."
msgstr ""
"Удалить тег \"%s\"?\n"
"\n"
"Все заметки, связанные с этим тегом, останутся, но тег будет удалён со всех "
"заметок."
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.ts:38
#: packages/app-mobile/components/side-menu-content.tsx:414
@@ -2028,7 +2027,7 @@ msgstr "Отображает полную информацию о заметке
#: packages/app-cli/app/command-keymap.ts:14
msgid "Displays the configured keyboard shortcuts."
msgstr "Отображает настроенные сочетания клавиш."
msgstr ""
#: packages/app-cli/app/command-cat.ts:14
msgid "Displays the given note."
@@ -2077,7 +2076,7 @@ msgstr ""
#: packages/app-mobile/components/FeedbackBanner.tsx:167
msgid "Do you find the Joplin web app useful?"
msgstr "Вы находите веб-приложение Joplin полезным?"
msgstr ""
#: packages/lib/models/settings/builtInMetadata.ts:2003
msgid "Document scanner: Title template"
@@ -2248,8 +2247,9 @@ msgid "Edit profile configuration..."
msgstr "Редактирование конфигурации профиля..."
#: packages/app-mobile/components/screens/tags.tsx:64
#, fuzzy
msgid "Edit tag"
msgstr "Редактировать тег"
msgstr "Редактировать заметку."
#: packages/app-desktop/gui/MainScreen.tsx:129
#: packages/app-desktop/gui/NoteContentPropertiesDialog.tsx:151
@@ -2359,8 +2359,9 @@ msgid "Enable abbreviation syntax"
msgstr "Включить синтаксис аббревиатур"
#: packages/lib/models/settings/builtInMetadata.ts:1093
#, fuzzy
msgid "Enable ABC musical notation support"
msgstr "Включить поддержку музыкальной нотации ABC"
msgstr "Включить поддержку синтаксиса Fountain"
#: packages/lib/models/settings/builtInMetadata.ts:1095
msgid "Enable audio player"
@@ -2567,7 +2568,7 @@ msgstr "Введите код здесь"
#: packages/app-desktop/gui/SsoLoginScreen/SsoLoginScreen.tsx:43
msgid "Enter the code:"
msgstr "Введите код:"
msgstr "Введите код здесь"
#: packages/app-mobile/components/NoteItem.tsx:130
msgid "Entering selection mode"
@@ -2647,8 +2648,9 @@ msgstr "Редактировать блокнот"
#: packages/app-mobile/components/screens/Note/Note.tsx:1758
#: packages/app-mobile/components/screens/NoteRevisionViewer.tsx:225
#, fuzzy
msgid "Expand title"
msgstr "Развернуть заголовок"
msgstr "Развернуть"
#: packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.tsx:21
msgid "Expanded"
@@ -2758,7 +2760,7 @@ msgstr "Флаги функций"
#: packages/app-mobile/components/FeedbackBanner.tsx:180
msgid "Feedback"
msgstr "Обратная связь"
msgstr ""
#: packages/lib/Synchronizer.ts:207
msgid "Fetched items: %d/%d."
@@ -3139,6 +3141,7 @@ msgid "Import or export your data"
msgstr "Импорт или экспорт ваших данных"
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.ts:20
#, fuzzy
msgid "Import..."
msgstr "Импортирование..."
@@ -3241,7 +3244,7 @@ msgstr "В: %s"
#: packages/app-cli/app/command-share.ts:170
msgid "Incoming shares:"
msgstr "Поступающие ресурсы:"
msgstr "Поступающие ресурсы"
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.tsx:69
msgid "Incompatible"
@@ -3452,8 +3455,9 @@ msgid "Joplin Server"
msgstr "Сервер Joplin"
#: packages/lib/SyncTargetJoplinServerSAML.ts:68
#, fuzzy
msgid "Joplin Server (SAML)"
msgstr "Joplin Server (SAML)"
msgstr "URL сервера Joplin"
#: packages/lib/utils/joplinCloud/index.ts:454
msgid "Joplin Server Business"
@@ -3486,9 +3490,6 @@ msgid ""
"Joplin supports saving the location at which notes are saved or created. Do "
"you want to enable it? This can be changed at any time in settings."
msgstr ""
"Joplin поддерживает сохранение местоположения, в котором сохраняются или "
"создаются заметки. Вы хотите включить его? Это можно изменить в любое время "
"в настройках."
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:132
msgid ""
@@ -3538,7 +3539,7 @@ msgstr "Поддерживаемая связка ключей: %s"
#: packages/app-cli/app/command-keymap.ts:30
msgid "KEYS"
msgstr "КЛЮЧИ"
msgstr ""
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:74
msgid "Keys that need upgrading"
@@ -3546,7 +3547,7 @@ msgstr "Ключи, которые нуждаются в обновлении"
#: packages/editor/ProseMirror/plugins/imagePlugin.ts:265
msgid "Label"
msgstr "Метка"
msgstr ""
#: packages/lib/models/settings/builtInMetadata.ts:1447
msgid "Landscape"
@@ -3636,9 +3637,9 @@ msgstr "Ссылка"
#: packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.ts:21
msgid "Link created!"
msgid_plural "Links created!"
msgstr[0] "Ссылка создана!"
msgstr[1] "Ссылки созданы!"
msgstr[2] "Ссылок создано!"
msgstr[0] "Текст ссылки"
msgstr[1] "Текст ссылки"
msgstr[2] "Текст ссылки"
#: packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx:92
msgid "Link description"
@@ -3771,8 +3772,6 @@ msgid ""
"Manage your profiles. You can rename or delete profiles. The active profile "
"cannot be deleted."
msgstr ""
"Управляйте своими профилями. Вы можете переименовывать или удалять профили. "
"Активный профиль нельзя удалить."
#. `generate-ppk`
#: packages/app-cli/app/command-e2ee.ts:19
@@ -3805,12 +3804,14 @@ msgid "Markdown editor"
msgstr "Редактор Markdown"
#: packages/lib/models/settings/builtInMetadata.ts:1519
#, fuzzy
msgid "Markdown editor: Highlight active line"
msgstr "Редактор Markdown: выделять активную строку"
msgstr "Редактор Markdown"
#: packages/lib/models/settings/builtInMetadata.ts:1508
#, fuzzy
msgid "Markdown editor: Render images"
msgstr "Редактор Markdown: отображать изображения"
msgstr "Редактор Markdown"
#: packages/lib/models/settings/builtInMetadata.ts:1498
msgid "Markdown editor: Render markup in editor"
@@ -3893,7 +3894,7 @@ msgstr "Недостающие мастер-ключи"
#: packages/app-mobile/components/voiceTyping/AudioRecordingBanner.tsx:121
msgid "Missing permission to record audio."
msgstr "Отсутствует разрешение для записи аудио."
msgstr "Отсутствует разрешение для камеры"
#: packages/app-cli/app/cli-utils.js:112
msgid "Missing required argument: %s"
@@ -3936,16 +3937,16 @@ msgstr[1] "Переместить %d заметок в блокнот \"%s\"?"
msgstr[2] "Переместить %d заметок в блокнот \"%s\"?"
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.ts:34
#, fuzzy
msgid ""
"Move %d notebooks to the trash?\n"
"\n"
"All notes and sub-notebooks within these notebooks will also be moved to the "
"trash."
msgstr ""
"Переместить %d блокнотов в корзину?\n"
"Переместить блокнот “%s” в корзину?\n"
"\n"
"Все заметки и подблокноты внутри этих блокнотов также будут перемещены в "
"корзину."
"Все заметки и вложенные блокноты также будут перемещены в корзину."
#: packages/app-desktop/gui/ResizableLayout/MoveButtons.tsx:73
msgid "Move down"
@@ -4049,7 +4050,7 @@ msgstr ""
#: packages/app-mobile/components/FolderPicker.tsx:71
msgid "New notebook title"
msgstr "Название нового блокнота"
msgstr "Название блокнота:"
#: packages/app-mobile/setupQuickActions.ts:32
msgid "New photo"
@@ -4167,7 +4168,7 @@ msgstr "Вкладка не выбрана"
#: packages/app-mobile/components/TagEditor.tsx:183
msgid "No tags"
msgstr "Нет тегов"
msgstr "Новые метки:"
#: packages/app-cli/app/command-edit.ts:31
msgid ""
@@ -4182,7 +4183,7 @@ msgstr "Нет доступных обновлений"
#: packages/lib/components/shared/SamlShared.ts:12
msgid "No URL for SAML authentication set."
msgstr "URL для аутентификации SAML не задан."
msgstr ""
#: packages/app-cli/app/command-share.ts:188
#: packages/app-cli/app/command-share.ts:208
@@ -4218,7 +4219,7 @@ msgstr "Не сейчас"
#: packages/app-mobile/components/FeedbackBanner.tsx:188
msgid "Not useful"
msgstr "Бесполезный"
msgstr ""
#: packages/app-desktop/gui/NoteListControls/NoteListControls.tsx:110
#: packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx:71
@@ -4295,8 +4296,9 @@ msgid "Note list style"
msgstr "Стиль списка заметок"
#: packages/app-cli/app/command-unpublish.ts:41
#, fuzzy
msgid "Note not published: %s"
msgstr "Заметка не опубликована: %s"
msgstr "Содержимое предоставлено: %s"
#: packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.tsx:140
msgid "Note preview"
@@ -4528,7 +4530,7 @@ msgstr "Открыть мастер синхронизации..."
#: packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx:635
msgid "Open-source licences"
msgstr "Лицензия open-source"
msgstr ""
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:87
msgid "Open..."
@@ -4567,8 +4569,6 @@ msgid ""
"Options that should be used whenever rendering ABC code. It must be a JSON5 "
"object. The full list of options is available at: %s"
msgstr ""
"Опции, которые следует использовать при визуализации кода ABC. Это должен "
"быть объект JSON5. Полный список опций доступен по адресу: %s"
#: packages/app-mobile/components/NoteEditor/commandDeclarations.ts:103
msgid "Ordered list"
@@ -4576,7 +4576,7 @@ msgstr "Упорядоченный список"
#: packages/app-mobile/components/SyncWizard/SyncWizard.tsx:130
msgid "Other"
msgstr "Другое"
msgstr ""
#: packages/app-desktop/gui/MenuBar.tsx:459
msgid "Other applications..."
@@ -4937,8 +4937,9 @@ msgid "Profile name"
msgstr "Название профиля"
#: packages/app-desktop/gui/ProfileEditor.tsx:120
#, fuzzy
msgid "Profile name cannot be empty"
msgstr "Имя профиля не может быть пустым"
msgstr "Подтверждение пароля не может быть пустым"
#: packages/app-desktop/gui/ProfileEditor.tsx:116
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.ts:18
@@ -4978,8 +4979,9 @@ msgid "Publish Note"
msgstr "Публиковать заметку"
#: packages/app-cli/app/command-publish.ts:47
#, fuzzy
msgid "Publish note \"%s\" (in notebook \"%s\")?"
msgstr "Опубликовать заметку \"%s\" (в блокноте \"%s\")?"
msgstr "Переместить %d заметок в блокнот \"%s\"?"
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.ts:6
msgid "Publish note..."
@@ -5000,13 +5002,14 @@ msgid "Publish/unpublish"
msgstr "Опубликовать/Отменить публикацию"
#: packages/app-cli/app/command-publish.ts:60
#, fuzzy
msgid "Published at URL: %s"
msgstr "Опубликовано по адресу URL: %s"
msgstr "Публиковать заметку"
#: packages/app-cli/app/command-publish.ts:27
#: packages/app-cli/app/command-unpublish.ts:24
msgid "Publishes a note to Joplin Server or Joplin Cloud"
msgstr "Публикует заметку на Joplin Server или Joplin Cloud"
msgstr ""
#: packages/app-mobile/components/CameraView/ScannedBarcodes.tsx:79
msgid "QR Code"
@@ -5053,7 +5056,7 @@ msgstr "Продолжительность чтения: %s мин"
#: packages/app-cli/app/command-batch.ts:45
msgid "Reading commands from standard input is only available in CLI mode."
msgstr "Чтение команд из стандартного ввода доступно только в режиме CLI."
msgstr "Команда \"%s\" доступна только в режиме GUI"
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx:303
msgid "Recipient has accepted the invitation"
@@ -5072,12 +5075,13 @@ msgid "Recipients:"
msgstr "Получатели:"
#: packages/app-mobile/components/screens/DocumentScanner/NotePreview.tsx:162
#, fuzzy
msgid "Recognise text:"
msgstr "Распознать текст:"
msgstr "курсивный текст"
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:142
msgid "Recognize handwritten image"
msgstr "Распознать рукописное изображение"
msgstr "Изменение размера больших изображений:"
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/RecommendedBadge.tsx:72
msgid "Recommended"
@@ -5131,8 +5135,11 @@ msgid "Remove"
msgstr "Удалить"
#: packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx:134
#, fuzzy
msgid "Remove %d tags from all notes? This cannot be undone."
msgstr "Удалить %d тегов из всех заметок? Это действие нельзя отменить."
msgstr ""
"Удалить модель и перезагрузить?\n"
"Это невозможно отменить."
#: packages/app-mobile/components/TagEditor.tsx:136
msgid "Remove %s"
@@ -5152,11 +5159,11 @@ msgstr "Удалить этот поиск из боковой панели?"
#: packages/app-cli/app/command-share.ts:112
msgid "Removed %s from share."
msgstr "Удалено %s из общего ресурса."
msgstr "Удалить %s из общего ресурса"
#: packages/app-mobile/components/TagEditor.tsx:247
msgid "Removed tag: %s"
msgstr "Удален тег: %s"
msgstr "Переименовать метку:"
#: packages/app-desktop/gui/ProfileEditor.tsx:68
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.ts:8
@@ -5429,8 +5436,9 @@ msgid "Save geo-location with notes"
msgstr "Сохранять информацию о географическом местоположении в заметках"
#: packages/app-mobile/components/screens/Note/Note.tsx:575
#, fuzzy
msgid "Save geolocation?"
msgstr "Сохранить геолокацию?"
msgstr "Сохранять информацию о географическом местоположении в заметках"
#: packages/app-mobile/components/screens/Notes/NewNoteButton.tsx:81
msgid "Scan notebook"
@@ -5541,7 +5549,7 @@ msgstr "Выбор блокнота"
#: packages/app-mobile/components/SyncWizard/SyncWizard.tsx:131
msgid "Select one of the other supported sync targets."
msgstr "Выберите одну из других поддерживаемых целей синхронизации."
msgstr ""
#: packages/app-mobile/components/screens/folder.js:109
msgid "Select parent notebook"
@@ -5549,7 +5557,7 @@ msgstr "Выбрать родительский блокнот"
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.ts:42
msgid "Select the type of file to be imported:"
msgstr "Выберите тип файла для импорта:"
msgstr ""
#: packages/app-mobile/components/ComboBox.tsx:378
msgid "Selected: %s"
@@ -5761,7 +5769,7 @@ msgstr "Боковая панель"
#: packages/app-desktop/gui/JoplinCloudSignUpCallToAction.tsx:15
msgid "Sign up to Joplin Cloud"
msgstr "Войти в Joplin Cloud"
msgstr "Войти в Joplin Cloud."
#: packages/app-desktop/gui/ResourceScreen.tsx:117
msgid "Size"
@@ -5877,7 +5885,7 @@ msgstr "Источник: "
#: packages/app-cli/app/command-keymap.ts:35
msgid "SPACE"
msgstr "ПРОСТРАНСТВО"
msgstr ""
#: packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx:456
msgid "Spacer"
@@ -6087,7 +6095,7 @@ msgstr ""
#: packages/app-mobile/components/SyncWizard/SyncWizard.tsx:111
msgid "Sync"
msgstr "Синхронизация"
msgstr ""
#: packages/lib/utils/joplinCloud/index.ts:170
msgid "Sync as many devices as you want"
@@ -6188,16 +6196,18 @@ msgid "Tab moves focus"
msgstr "\"Tab\" cмещает фокус"
#: packages/app-mobile/components/NoteEditor/commandDeclarations.ts:118
#, fuzzy
msgid "Table"
msgstr "Таблица"
msgstr "Включить"
#: packages/lib/models/settings/builtInMetadata.ts:1440
msgid "Tabloid"
msgstr "Таблоид"
#: packages/app-mobile/components/screens/tags.tsx:206
#, fuzzy
msgid "Tag: %s"
msgstr "Тег: %s"
msgstr "Использовано: %s"
#: packages/app-cli/app/command-import.ts:58
#: packages/app-desktop/gui/ImportScreen.tsx:94
@@ -6219,8 +6229,9 @@ msgid "Take photo"
msgstr "Сделать фото"
#: packages/app-mobile/components/FeedbackBanner.tsx:164
#, fuzzy
msgid "Take survey"
msgstr "Пройти опрос"
msgstr "Нарисовать картинку"
#: packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/TaskButton.tsx:73
msgid "Task \"%s\" failed with error: %s"
@@ -6252,8 +6263,6 @@ msgid ""
"Thank you for the feedback!\n"
"Do you have time to complete a short survey?"
msgstr ""
"Спасибо за отзыв!\n"
"У вас есть время заполнить небольшой опрос?"
#: packages/app-desktop/gui/ProfileEditor.tsx:143
#: packages/lib/services/profileConfig/index.ts:106
@@ -6420,21 +6429,16 @@ msgid "The note \"%s\" has been successfully restored to the notebook \"%s\"."
msgstr "Заметка “%s” была успешно восстановлена в блокнот “%s”."
#: packages/lib/commands/convertNoteToMarkdown.ts:64
#, fuzzy
msgid ""
"The note has been converted to Markdown and the original note has been moved "
"to the trash"
msgid_plural ""
"The notes have been converted to Markdown and the original notes have been "
"moved to the trash"
msgstr[0] ""
"Заметка была преобразована в Markdown, а оригинальные заметки перемещены в "
"корзину"
msgstr[1] ""
"Заметки были преобразованы в Markdown, а оригинальные заметки перемещены в "
"корзину"
msgstr[2] ""
"Заметок были преобразованы в Markdown, а оригинальные заметки перемещены в "
"корзину"
msgstr[0] "Блокнот и его содержимое были успешно перемещены в корзину."
msgstr[1] "Блокнот и его содержимое были успешно перемещены в корзину."
msgstr[2] "Блокнот и его содержимое были успешно перемещены в корзину."
#: packages/app-desktop/gui/TrashNotification/TrashNotification.tsx:45
msgid "The note was successfully moved to the trash."
@@ -7012,7 +7016,7 @@ msgstr "Попробуйте прямо сейчас"
#: packages/app-cli/app/command-keymap.ts:30
msgid "TYPE"
msgstr "ТИП"
msgstr ""
#: packages/app-cli/app/command-help.ts:72
msgid ""
@@ -7052,8 +7056,9 @@ msgid "Unable to share log data. Reason: %s"
msgstr "Невозможно поделиться данными журнала. Причина: %s"
#: packages/app-mobile/components/screens/Note/Note.tsx:1125
#, fuzzy
msgid "Unable to share note data. Reason: %s"
msgstr "Не удается поделиться данными заметки. Причина: %s"
msgstr "Невозможно поделиться данными журнала. Причина: %s"
#: packages/app-mobile/components/Checkbox.tsx:51
msgid "Unchecked"
@@ -7318,7 +7323,7 @@ msgstr ""
#: packages/app-mobile/components/FeedbackBanner.tsx:195
msgid "Useful"
msgstr "Полезный"
msgstr ""
#: packages/server/src/services/MustacheService.ts:125
msgid "User deletions"
@@ -7498,9 +7503,6 @@ msgid ""
"higher-quality on-server transcription service. Requires sync with a copy of "
"the desktop app."
msgstr ""
"Когда включено, запросы на транскрипцию изображений в заметке выполняются с "
"использованием более качественного сервиса транскрипции на сервере. "
"Требуется синхронизация с копией настольного приложения."
#: packages/lib/models/settings/builtInMetadata.ts:577
msgid ""
+2 -2
View File
@@ -44,7 +44,7 @@
"yargs": "17.7.2"
},
"devDependencies": {
"@docusaurus/plugin-sitemap": "3.9.2",
"@docusaurus/plugin-sitemap": "2.4.3",
"@joplin/fork-htmlparser2": "^4.1.60",
"@rmp135/sql-ts": "1.18.1",
"@types/fs-extra": "11.0.4",
@@ -61,7 +61,7 @@
"jest": "29.7.0",
"js-yaml": "4.1.1",
"rss": "1.2.2",
"sass": "1.97.2",
"sass": "1.96.0",
"sqlite3": "5.1.6",
"style-to-js": "1.1.21",
"ts-node": "10.9.2",
+104 -5813
View File
File diff suppressed because it is too large Load Diff